Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27451807db | ||
|
|
55099bdcbb | ||
|
|
71eb720065 | ||
|
|
8abb0c8717 | ||
|
|
3043639d2d | ||
|
|
e9b3eacbb7 | ||
|
|
f37e55e285 | ||
|
|
c0af2dbdd1 | ||
|
|
ae517732f7 | ||
|
|
3e1de10213 | ||
|
|
511740bb6d | ||
|
|
4a24d2a11d | ||
|
|
aadf56c27b | ||
|
|
9f8588eadc | ||
|
|
d657aec391 | ||
|
|
e683c90404 | ||
|
|
cfb4a23c9d | ||
|
|
fe1d4861bb | ||
|
|
5aa9b1d5f9 | ||
|
|
9c7452e7fd |
+11
-3
@@ -71,11 +71,19 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
|
||||
# Directory for storing encrypted settings files (default: ./data/settings).
|
||||
# For Docker, the working directory is /app, so the default resolves to
|
||||
# /app/data/settings - mount a persistent volume there:
|
||||
# volumes:
|
||||
# - bulwark-settings:/app/data/settings
|
||||
# /app/data/settings - mount a persistent volume there (see docker-compose.yml).
|
||||
# SETTINGS_DATA_DIR=./data/settings
|
||||
|
||||
# =============================================================================
|
||||
# Admin Dashboard Data
|
||||
# =============================================================================
|
||||
|
||||
# Directory for admin dashboard state: config overrides, admin password hash,
|
||||
# installed plugins/themes, and audit logs (default: ./data/admin).
|
||||
# For Docker, the default resolves to /app/data/admin - mount a persistent
|
||||
# volume there (see docker-compose.yml).
|
||||
# ADMIN_DATA_DIR=./data/admin
|
||||
|
||||
# =============================================================================
|
||||
# Server Listen Address
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## 1.5.2 (2026-04-27)
|
||||
|
||||
### Features
|
||||
|
||||
- **Plugins**: New `composer-sidebar` slot and `ui:composer-sidebar` permission — plugins can now render a panel on either side of the New Message dialog. See `repos/subway-surfers` for an example
|
||||
- **Plugins**: Manifests can declare `frameOrigins` — a strictly-validated list of `https://host` origins the plugin needs to embed. The proxy reads the union from enabled plugins and merges it into the host CSP `frame-src`, so the host CSP no longer needs to know about specific embed providers
|
||||
- **Calendar/Contacts**: JMAP sharing for calendars and address books
|
||||
- **i18n**: Czech language support
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Security**: Validate URLs before outbound fetch
|
||||
- **Calendar**: Prevent drag creation on touch events in the time grid
|
||||
- **Contacts**: Emit RFC 9553 name kinds and decode QUOTED-PRINTABLE in vCard import (#224, #187)
|
||||
- **Mail**: Hide preview line in compact density to match settings preview (#223)
|
||||
- **Proxy**: Inline matcher for Next.js proxy and drop unnecessary Node.js runtime config
|
||||
- **i18n**: Portuguese fixes for "ficheiro" and "contactos" variants
|
||||
|
||||
## 1.5.1 (2026-04-25)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
|
||||
</div>
|
||||
@@ -62,7 +62,7 @@ Bulwark is a full webmail suite – not just an inbox. It bundles the four apps
|
||||
- **Contacts** – multiple address books, groups, vCard import/export
|
||||
- **Files** – Stalwart's JMAP FileNode storage with previews and folder upload
|
||||
|
||||
Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 14 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a admin dashboard.
|
||||
Plus the infrastructure around them: OAuth2 / OIDC SSO, TOTP 2FA, multi-account (up to 5 at once), 15 languages, PWA install, dark/light themes, a plugin system with an extension marketplace, and a admin dashboard.
|
||||
|
||||
Full feature list: **[FEATURES.md](FEATURES.md)**.
|
||||
|
||||
|
||||
@@ -47,7 +47,11 @@ import { getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } from "@/lib/jmap/types";
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
|
||||
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { debug } from "@/lib/debug";
|
||||
@@ -72,7 +76,8 @@ export default function CalendarPage() {
|
||||
calendars, events, selectedDate, viewMode, selectedCalendarIds,
|
||||
isLoading, isLoadingEvents, supportsCalendar, error,
|
||||
fetchCalendars, fetchEvents, createEvent, updateEvent, deleteEvent, rsvpEvent,
|
||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar,
|
||||
setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar,
|
||||
removeCalendar, clearCalendarEvents,
|
||||
refreshAllSubscriptions, icalSubscriptions,
|
||||
} = useCalendarStore();
|
||||
const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore();
|
||||
@@ -91,6 +96,11 @@ export default function CalendarPage() {
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||
const [editingSubscription, setEditingSubscription] = useState<string | null>(null);
|
||||
const [sharingCalendarId, setSharingCalendarId] = useState<string | null>(null);
|
||||
const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState<string | undefined>(undefined);
|
||||
const [showCreateCalendar, setShowCreateCalendar] = useState(false);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useConfirmDialog();
|
||||
const tMgmt = useTranslations("calendar.management");
|
||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
||||
@@ -1102,6 +1112,42 @@ export default function CalendarPage() {
|
||||
}
|
||||
updateCalendar(client, calendarId, { color });
|
||||
} : undefined}
|
||||
onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined}
|
||||
onCreateEvent={(cal: Calendar) => {
|
||||
setDefaultCalendarIdForCreate(cal.id);
|
||||
openCreateModal();
|
||||
}}
|
||||
onClearCalendar={client ? async (cal: Calendar) => {
|
||||
const ok = await confirmAction({
|
||||
title: tMgmt("clear_events"),
|
||||
message: tMgmt("confirm_clear", { name: cal.name }),
|
||||
variant: "destructive",
|
||||
confirmText: tMgmt("clear_events"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const count = await clearCalendarEvents(client, cal.id);
|
||||
toast.success(tMgmt("events_cleared", { count }));
|
||||
} catch {
|
||||
toast.error(tMgmt("error_clear"));
|
||||
}
|
||||
} : undefined}
|
||||
onDeleteCalendar={client ? async (cal: Calendar) => {
|
||||
const ok = await confirmAction({
|
||||
title: tMgmt("delete"),
|
||||
message: tMgmt("confirm_delete", { name: cal.name }),
|
||||
variant: "destructive",
|
||||
confirmText: tMgmt("delete"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await removeCalendar(client, cal.id);
|
||||
toast.success(tMgmt("calendar_deleted"));
|
||||
} catch {
|
||||
toast.error(tMgmt("error_delete"));
|
||||
}
|
||||
} : undefined}
|
||||
onCreateCalendar={client ? () => setShowCreateCalendar(true) : undefined}
|
||||
onSubscribe={() => setShowSubscriptionModal(true)}
|
||||
onEditSubscription={(subId) => setEditingSubscription(subId)}
|
||||
client={client}
|
||||
@@ -1165,11 +1211,12 @@ export default function CalendarPage() {
|
||||
calendars={calendars}
|
||||
defaultDate={defaultModalDate}
|
||||
defaultEndDate={defaultModalEndDate}
|
||||
defaultCalendarId={defaultCalendarIdForCreate}
|
||||
onSave={handleSaveEvent}
|
||||
onDelete={handleDeleteEvent}
|
||||
onDuplicate={handleDuplicateEvent}
|
||||
onRsvp={handleRsvp}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); }}
|
||||
onPreviewChange={setPendingPreview}
|
||||
currentUserEmails={currentUserEmails}
|
||||
isMobile={false}
|
||||
@@ -1261,11 +1308,12 @@ export default function CalendarPage() {
|
||||
calendars={calendars}
|
||||
defaultDate={defaultModalDate}
|
||||
defaultEndDate={defaultModalEndDate}
|
||||
defaultCalendarId={defaultCalendarIdForCreate}
|
||||
onSave={handleSaveEvent}
|
||||
onDelete={handleDeleteEvent}
|
||||
onDuplicate={handleDuplicateEvent}
|
||||
onRsvp={handleRsvp}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); }}
|
||||
currentUserEmails={currentUserEmails}
|
||||
isMobile={true}
|
||||
/>
|
||||
@@ -1305,6 +1353,33 @@ export default function CalendarPage() {
|
||||
onSelect={handleScopeSelect}
|
||||
onClose={() => setPendingScopeAction(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
|
||||
{showCreateCalendar && client && (
|
||||
<CreateCalendarModal
|
||||
client={client}
|
||||
onClose={() => setShowCreateCalendar(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sharingCalendarId && client && (() => {
|
||||
const cal = allCalendars.find((c) => c.id === sharingCalendarId);
|
||||
if (!cal) return null;
|
||||
return (
|
||||
<ShareCollectionDialog
|
||||
client={client}
|
||||
kind="calendar"
|
||||
collectionName={cal.name}
|
||||
shareWith={cal.shareWith}
|
||||
ownAccountId={client.getAccountId()}
|
||||
onShare={async (principalId, rights) => {
|
||||
await shareCalendar(client, cal.id, principalId, rights as CalendarRights | null);
|
||||
}}
|
||||
onClose={() => setSharingCalendarId(null)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
|
||||
type View =
|
||||
| "list"
|
||||
@@ -75,6 +76,8 @@ export default function ContactsPage() {
|
||||
bulkAddToGroup,
|
||||
moveContactToAddressBook,
|
||||
renameAddressBook,
|
||||
removeAddressBook,
|
||||
shareAddressBook,
|
||||
renameKeyword,
|
||||
importContacts,
|
||||
} = useContactStore();
|
||||
@@ -83,6 +86,8 @@ export default function ContactsPage() {
|
||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
||||
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
||||
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
|
||||
const [renamingKeyword, setRenamingKeyword] = useState<string | null>(null);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const hasFetched = useRef(false);
|
||||
@@ -329,6 +334,7 @@ export default function ContactsPage() {
|
||||
addLocalContact(localContact);
|
||||
toast.success(t("toast.created"));
|
||||
}
|
||||
setDefaultBookIdForCreate(undefined);
|
||||
setView("list");
|
||||
}, [supportsSync, client, createContact, addLocalContact, t]);
|
||||
|
||||
@@ -346,6 +352,7 @@ export default function ContactsPage() {
|
||||
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
||||
|
||||
const handleCancel = () => {
|
||||
setDefaultBookIdForCreate(undefined);
|
||||
if (view === "group-create" || view === "group-edit") {
|
||||
setView(selectedGroup ? "group-detail" : "list");
|
||||
} else if (view === "bulk-add-to-group") {
|
||||
@@ -517,7 +524,7 @@ export default function ContactsPage() {
|
||||
const renderRightPanel = () => {
|
||||
switch (view) {
|
||||
case "create":
|
||||
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||
|
||||
case "edit":
|
||||
if (!selectedContact) return null;
|
||||
@@ -690,6 +697,26 @@ export default function ContactsPage() {
|
||||
onDropContacts={handleDropContacts}
|
||||
onDropContactsToCategory={handleDropContactsToCategory}
|
||||
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||
onShareAddressBook={client ? (book) => setSharingAddressBookId(book.id) : undefined}
|
||||
onCreateContactInBook={(book) => {
|
||||
setDefaultBookIdForCreate(book.id);
|
||||
handleCreateNew();
|
||||
}}
|
||||
onDeleteAddressBook={client ? async (book) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("address_books.delete"),
|
||||
message: t("address_books.confirm_delete", { name: book.name }),
|
||||
variant: "destructive",
|
||||
confirmText: t("address_books.delete"),
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await removeAddressBook(client, book);
|
||||
toast.success(t("address_books.deleted"));
|
||||
} catch {
|
||||
toast.error(t("address_books.delete_failed"));
|
||||
}
|
||||
} : undefined}
|
||||
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
||||
/>
|
||||
</div>
|
||||
@@ -838,6 +865,23 @@ export default function ContactsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sharingAddressBookId && client && (() => {
|
||||
const book = addressBooks.find((b) => b.id === sharingAddressBookId);
|
||||
if (!book) return null;
|
||||
return (
|
||||
<ShareCollectionDialog
|
||||
client={client}
|
||||
kind="addressBook"
|
||||
collectionName={book.name}
|
||||
shareWith={book.shareWith}
|
||||
ownAccountId={client.getAccountId()}
|
||||
onShare={async (principalId, rights) => {
|
||||
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
|
||||
}}
|
||||
onClose={() => setSharingAddressBookId(null)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -10,6 +10,10 @@ import {
|
||||
type ServerPlugin,
|
||||
type ServerTheme,
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
import {
|
||||
sanitizeFrameOrigins,
|
||||
invalidateFrameOriginsCache,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
import JSZip from 'jszip';
|
||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||
@@ -226,6 +230,22 @@ export async function POST(request: NextRequest) {
|
||||
warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
|
||||
}
|
||||
|
||||
// Plugins may declare iframe origins they need for embedded content.
|
||||
// Anything that doesn't pass strict origin validation is silently
|
||||
// dropped — the plugin still installs, but those origins are not
|
||||
// added to the host CSP.
|
||||
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||
const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
|
||||
? (manifest.frameOrigins as unknown[]).filter(
|
||||
(v) => typeof v !== 'string' || !declaredFrameOrigins.includes(v),
|
||||
)
|
||||
: [];
|
||||
if (droppedFrameOrigins.length > 0) {
|
||||
warnings.push(
|
||||
`Ignored invalid frameOrigins: ${droppedFrameOrigins.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const plugin: ServerPlugin = {
|
||||
id: (manifest.id as string) || slug,
|
||||
name: (manifest.name as string) || slug,
|
||||
@@ -238,10 +258,14 @@ export async function POST(request: NextRequest) {
|
||||
enabled: true,
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
...(declaredFrameOrigins.length > 0
|
||||
? { frameOrigins: declaredFrameOrigins }
|
||||
: {}),
|
||||
};
|
||||
|
||||
await savePlugin(plugin, code);
|
||||
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug }, ip);
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins }, ip);
|
||||
|
||||
return NextResponse.json({ success: true, plugin, warnings });
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
deletePlugin as removePlugin,
|
||||
type ServerPlugin,
|
||||
} from '@/lib/admin/plugin-registry';
|
||||
import {
|
||||
sanitizeFrameOrigins,
|
||||
invalidateFrameOriginsCache,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
|
||||
// Server-side extraction using the same validation logic
|
||||
// ZIP parsing needs to happen on the server for admin-uploaded plugins
|
||||
@@ -152,6 +156,8 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const plugin: ServerPlugin = {
|
||||
id: manifest.id as string,
|
||||
@@ -166,12 +172,16 @@ export async function POST(request: NextRequest) {
|
||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||
: {}),
|
||||
...(declaredFrameOrigins.length > 0
|
||||
? { frameOrigins: declaredFrameOrigins }
|
||||
: {}),
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await savePlugin(plugin, code);
|
||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip);
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins }, ip);
|
||||
|
||||
return NextResponse.json({ plugin });
|
||||
} catch (error) {
|
||||
@@ -209,6 +219,11 @@ export async function PATCH(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Enable/disable changes the set of plugins contributing frame origins.
|
||||
if (typeof updates.enabled === 'boolean' || typeof updates.forceEnabled === 'boolean') {
|
||||
invalidateFrameOriginsCache();
|
||||
}
|
||||
|
||||
await auditLog('plugin.update', { id, ...updates }, ip);
|
||||
return NextResponse.json({ plugin: updated });
|
||||
} catch (error) {
|
||||
@@ -238,6 +253,7 @@ export async function DELETE(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
invalidateFrameOriginsCache();
|
||||
await auditLog('plugin.delete', { id }, ip);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,89 +1,9 @@
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { BlockList, isIP } from 'node:net';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
|
||||
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const FETCH_TIMEOUT_MS = 15000;
|
||||
|
||||
const blockedAddressRanges = new BlockList();
|
||||
blockedAddressRanges.addAddress('0.0.0.0');
|
||||
blockedAddressRanges.addAddress('127.0.0.1');
|
||||
blockedAddressRanges.addSubnet('10.0.0.0', 8);
|
||||
blockedAddressRanges.addSubnet('172.16.0.0', 12);
|
||||
blockedAddressRanges.addSubnet('192.168.0.0', 16);
|
||||
blockedAddressRanges.addSubnet('169.254.0.0', 16);
|
||||
blockedAddressRanges.addAddress('::', 'ipv6');
|
||||
blockedAddressRanges.addAddress('::1', 'ipv6');
|
||||
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
|
||||
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
|
||||
|
||||
function normalizeHostname(hostname: string): string {
|
||||
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
|
||||
}
|
||||
|
||||
function isBlockedIpAddress(hostname: string): boolean {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
const family = isIP(normalized);
|
||||
if (family === 4) {
|
||||
return blockedAddressRanges.check(normalized, 'ipv4');
|
||||
}
|
||||
if (family === 6) {
|
||||
return blockedAddressRanges.check(normalized, 'ipv6');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function isValidExternalUrl(urlString: string): Promise<boolean> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(urlString);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = normalizeHostname(url.hostname);
|
||||
|
||||
// Block private/internal hostnames
|
||||
if (
|
||||
hostname === 'localhost' ||
|
||||
hostname.endsWith('.localhost') ||
|
||||
hostname.endsWith('.local') ||
|
||||
hostname.endsWith('.internal') ||
|
||||
hostname.endsWith('.arpa') ||
|
||||
hostname.endsWith('.localdomain')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block URLs with credentials
|
||||
if (url.username || url.password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isBlockedIpAddress(hostname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isIP(hostname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const records = await lookup(hostname, { all: true, verbatim: true });
|
||||
if (records.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return records.every((record) => !isBlockedIpAddress(record.address));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let body: { url?: string };
|
||||
try {
|
||||
@@ -98,7 +18,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!(await isValidExternalUrl(url))) {
|
||||
if (!(await isPublicHttpUrl(url))) {
|
||||
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -111,7 +31,7 @@ export async function POST(request: NextRequest) {
|
||||
let response: Response | undefined;
|
||||
|
||||
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||
if (!(await isValidExternalUrl(currentUrl))) {
|
||||
if (!(await isPublicHttpUrl(currentUrl))) {
|
||||
clearTimeout(timeout);
|
||||
return NextResponse.json({ error: 'Redirect to disallowed URL' }, { status: 400 });
|
||||
}
|
||||
@@ -131,7 +51,6 @@ export async function POST(request: NextRequest) {
|
||||
clearTimeout(timeout);
|
||||
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
|
||||
}
|
||||
// Resolve relative redirects
|
||||
currentUrl = new URL(location, currentUrl).toString();
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake } from "lucide-react";
|
||||
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, Users, Plus, Eraser, Palette } from "lucide-react";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
import type { Calendar } from "@/lib/jmap/types";
|
||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||
@@ -11,6 +11,8 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useTaskStore } from "@/stores/task-store";
|
||||
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
interface CalendarSidebarPanelProps {
|
||||
@@ -18,6 +20,11 @@ interface CalendarSidebarPanelProps {
|
||||
selectedCalendarIds: string[];
|
||||
onToggleVisibility: (id: string) => void;
|
||||
onColorChange?: (calendarId: string, color: string) => void;
|
||||
onShareCalendar?: (calendar: Calendar) => void;
|
||||
onCreateEvent?: (calendar: Calendar) => void;
|
||||
onClearCalendar?: (calendar: Calendar) => void;
|
||||
onDeleteCalendar?: (calendar: Calendar) => void;
|
||||
onCreateCalendar?: () => void;
|
||||
onSubscribe?: () => void;
|
||||
onEditSubscription?: (subscriptionId: string) => void;
|
||||
client?: IJMAPClient | null;
|
||||
@@ -28,12 +35,18 @@ export function CalendarSidebarPanel({
|
||||
selectedCalendarIds,
|
||||
onToggleVisibility,
|
||||
onColorChange,
|
||||
onShareCalendar,
|
||||
onCreateEvent,
|
||||
onClearCalendar,
|
||||
onDeleteCalendar,
|
||||
onCreateCalendar,
|
||||
onSubscribe,
|
||||
onEditSubscription,
|
||||
client,
|
||||
}: CalendarSidebarPanelProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const tSub = useTranslations("calendar.subscription");
|
||||
const tMgmt = useTranslations("calendar.management");
|
||||
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
||||
const icalSubscriptions = useCalendarStore((s) => s.icalSubscriptions);
|
||||
const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription);
|
||||
@@ -49,11 +62,8 @@ export function CalendarSidebarPanel({
|
||||
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
|
||||
}, [tasks]);
|
||||
|
||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
||||
const [contextMenuCalId, setContextMenuCalId] = useState<string | null>(null);
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
|
||||
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
|
||||
const colorPickerRef = useRef<HTMLDivElement>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
|
||||
const sharedAccountGroups = useMemo(() => {
|
||||
@@ -69,30 +79,6 @@ export function CalendarSidebarPanel({
|
||||
return Array.from(groups.values());
|
||||
}, [calendars]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!colorPickerId && !contextMenuCalId) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
|
||||
setColorPickerId(null);
|
||||
}
|
||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
||||
setContextMenuCalId(null);
|
||||
}
|
||||
};
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setColorPickerId(null);
|
||||
setContextMenuCalId(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
};
|
||||
}, [colorPickerId, contextMenuCalId]);
|
||||
|
||||
const getSubscriptionForCalendar = (calendarId: string) => {
|
||||
return icalSubscriptions.find(s => s.calendarId === calendarId);
|
||||
};
|
||||
@@ -100,7 +86,6 @@ export function CalendarSidebarPanel({
|
||||
const handleRefreshSubscription = async (subId: string) => {
|
||||
if (!client) return;
|
||||
setRefreshingSubId(subId);
|
||||
setContextMenuCalId(null);
|
||||
try {
|
||||
await refreshICalSubscription(client, subId);
|
||||
toast.success(tSub('refresh_success'));
|
||||
@@ -113,7 +98,6 @@ export function CalendarSidebarPanel({
|
||||
|
||||
const handleUnsubscribe = async (subId: string) => {
|
||||
if (!client) return;
|
||||
setContextMenuCalId(null);
|
||||
try {
|
||||
await removeICalSubscription(client, subId);
|
||||
toast.success(tSub('deleted'));
|
||||
@@ -127,21 +111,13 @@ export function CalendarSidebarPanel({
|
||||
const renderCalendarItem = (cal: Calendar) => {
|
||||
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||
const color = cal.color || "#3b82f6";
|
||||
const hasMenu = isSubscriptionCalendar(cal.id) ? !!client : true;
|
||||
|
||||
return (
|
||||
<div key={cal.id} className="relative">
|
||||
<button
|
||||
onClick={() => onToggleVisibility(cal.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
if (isSubscriptionCalendar(cal.id) && client) {
|
||||
setContextMenuCalId(contextMenuCalId === cal.id ? null : cal.id);
|
||||
setColorPickerId(null);
|
||||
} else if (onColorChange) {
|
||||
setColorPickerId(colorPickerId === cal.id ? null : cal.id);
|
||||
setContextMenuCalId(null);
|
||||
}
|
||||
}}
|
||||
onContextMenu={hasMenu ? (e) => openContextMenu(e, cal) : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
|
||||
"hover:bg-muted"
|
||||
@@ -169,70 +145,101 @@ export function CalendarSidebarPanel({
|
||||
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Subscription context menu on right-click */}
|
||||
{contextMenuCalId === cal.id && isSubscriptionCalendar(cal.id) && client && (() => {
|
||||
const sub = getSubscriptionForCalendar(cal.id);
|
||||
if (!sub) return null;
|
||||
return (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg py-1 w-48"
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
setContextMenuCalId(null);
|
||||
onEditSubscription?.(sub.id);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
{tSub('edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRefreshSubscription(sub.id)}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-muted transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
{tSub('refresh')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUnsubscribe(sub.id)}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{tSub('unsubscribe')}
|
||||
</button>
|
||||
{sub.lastRefreshed && (
|
||||
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
|
||||
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Color picker popover on right-click */}
|
||||
{colorPickerId === cal.id && onColorChange && (
|
||||
<div
|
||||
ref={colorPickerRef}
|
||||
className="absolute left-6 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 w-56"
|
||||
>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">{t("management.change_color")}</p>
|
||||
<CalendarColorPicker
|
||||
value={color}
|
||||
onChange={(c) => {
|
||||
onColorChange(cal.id, c);
|
||||
setColorPickerId(null);
|
||||
}}
|
||||
allowCustom
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCalendarMenu = () => {
|
||||
const cal = contextMenu.data;
|
||||
if (!cal) return null;
|
||||
|
||||
if (isSubscriptionCalendar(cal.id)) {
|
||||
const sub = getSubscriptionForCalendar(cal.id);
|
||||
if (!sub || !client) return null;
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
|
||||
<ContextMenuItem
|
||||
icon={Pencil}
|
||||
label={tSub('edit')}
|
||||
onClick={() => { closeContextMenu(); onEditSubscription?.(sub.id); }}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={RefreshCw}
|
||||
label={tSub('refresh')}
|
||||
onClick={() => { closeContextMenu(); handleRefreshSubscription(sub.id); }}
|
||||
/>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={tSub('unsubscribe')}
|
||||
onClick={() => { closeContextMenu(); handleUnsubscribe(sub.id); }}
|
||||
destructive
|
||||
/>
|
||||
{sub.lastRefreshed && (
|
||||
<div className="px-3 py-1.5 text-xs text-muted-foreground border-t border-border mt-1 pt-1">
|
||||
{tSub('last_refreshed', { time: formatDateTime(sub.lastRefreshed, timeFormat, { month: 'short', day: 'numeric', year: 'numeric' }) })}
|
||||
</div>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID;
|
||||
const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false;
|
||||
const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared;
|
||||
const canChangeColor = !!onColorChange;
|
||||
const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false;
|
||||
const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared;
|
||||
const showSeparator = (canCreate || canShare || canChangeColor) && (canClear || canDelete);
|
||||
const color = cal.color || "#3b82f6";
|
||||
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
|
||||
{canCreate && (
|
||||
<ContextMenuItem
|
||||
icon={Plus}
|
||||
label={tMgmt('new_event_in_calendar')}
|
||||
onClick={() => { closeContextMenu(); onCreateEvent(cal); }}
|
||||
/>
|
||||
)}
|
||||
{canShare && (
|
||||
<ContextMenuItem
|
||||
icon={Users}
|
||||
label={tMgmt('share')}
|
||||
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
|
||||
/>
|
||||
)}
|
||||
{canChangeColor && (
|
||||
<ContextMenuSubMenu icon={Palette} label={tMgmt('change_color')}>
|
||||
<div className="px-2 py-1.5 w-[200px]">
|
||||
<CalendarColorPicker
|
||||
value={color}
|
||||
onChange={(c) => { onColorChange(cal.id, c); closeContextMenu(); }}
|
||||
allowCustom
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
{showSeparator && <ContextMenuSeparator />}
|
||||
{canClear && (
|
||||
<ContextMenuItem
|
||||
icon={Eraser}
|
||||
label={tMgmt('clear_events')}
|
||||
onClick={() => { closeContextMenu(); onClearCalendar(cal); }}
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={tMgmt('delete')}
|
||||
onClick={() => { closeContextMenu(); onDeleteCalendar(cal); }}
|
||||
destructive
|
||||
/>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
{enableCalendarTasks && (
|
||||
@@ -250,9 +257,22 @@ export function CalendarSidebarPanel({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
||||
{t("my_calendars")}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between mb-2 px-1 group">
|
||||
{onCreateCalendar ? (
|
||||
<button
|
||||
onClick={onCreateCalendar}
|
||||
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
|
||||
title={tMgmt('add_calendar')}
|
||||
>
|
||||
{t('my_calendars')}
|
||||
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
) : (
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{t('my_calendars')}
|
||||
</h3>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{personalCalendars.map(renderCalendarItem)}
|
||||
</div>
|
||||
@@ -268,6 +288,8 @@ export function CalendarSidebarPanel({
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{renderCalendarMenu()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Loader2, Calendar as CalendarIcon } from "lucide-react";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
interface CreateCalendarModalProps {
|
||||
client: IJMAPClient;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProps) {
|
||||
const t = useTranslations("calendar.management");
|
||||
const tCommon = useTranslations("common");
|
||||
const createCalendar = useCalendarStore((s) => s.createCalendar);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState("#3b82f6");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isValid = name.trim().length > 0;
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const created = await createCalendar(client, { name: trimmed, color });
|
||||
if (created) {
|
||||
toast.success(t("calendar_created"));
|
||||
onClose();
|
||||
} else {
|
||||
toast.error(t("error_create"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("error_create"));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [name, color, client, createCalendar, onClose, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !isSubmitting) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [onClose, isSubmitting]);
|
||||
|
||||
useEffect(() => {
|
||||
const modal = modalRef.current;
|
||||
if (!modal) return;
|
||||
const focusableEls = modal.querySelectorAll<HTMLElement>(
|
||||
'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
const firstEl = focusableEls[0];
|
||||
const lastEl = focusableEls[focusableEls.length - 1];
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab") return;
|
||||
if (e.shiftKey && document.activeElement === firstEl) {
|
||||
e.preventDefault();
|
||||
lastEl?.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === lastEl) {
|
||||
e.preventDefault();
|
||||
firstEl?.focus();
|
||||
}
|
||||
};
|
||||
modal.addEventListener("keydown", handler);
|
||||
firstEl?.focus();
|
||||
return () => modal.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
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={() => !isSubmitting && onClose()}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("add_calendar")}
|
||||
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("add_calendar")}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
aria-label={tCommon("close")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t("name")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("name_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"
|
||||
disabled={isSubmitting}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
{t("color")}
|
||||
</label>
|
||||
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
{tCommon("loading")}
|
||||
</>
|
||||
) : (
|
||||
t("create")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ interface EventModalProps {
|
||||
calendars: Calendar[];
|
||||
defaultDate?: Date;
|
||||
defaultEndDate?: Date;
|
||||
defaultCalendarId?: string;
|
||||
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
|
||||
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
||||
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
||||
@@ -113,6 +114,7 @@ export function EventModal({
|
||||
calendars,
|
||||
defaultDate,
|
||||
defaultEndDate,
|
||||
defaultCalendarId,
|
||||
onSave,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
@@ -200,6 +202,7 @@ export function EventModal({
|
||||
const [allDay, setAllDay] = useState(event?.showWithoutTime || false);
|
||||
const [calendarId, setCalendarId] = useState<string>(() => {
|
||||
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
|
||||
if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId;
|
||||
const defaultCal = calendars.find(c => c.isDefault);
|
||||
return defaultCal?.id || calendars[0]?.id || "";
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ interface ContactFormProps {
|
||||
contact?: ContactCard | null;
|
||||
addressBooks?: AddressBook[];
|
||||
allKeywords?: string[];
|
||||
defaultAddressBookId?: string;
|
||||
onSave: (data: Partial<ContactCard>) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -143,11 +144,13 @@ function Select({ value, onChange, children, className }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCancel }: ContactFormProps) {
|
||||
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, onSave, onCancel }: ContactFormProps) {
|
||||
const t = useTranslations("contacts.form");
|
||||
const isEditing = !!contact;
|
||||
|
||||
const findComponent = (kind: string) => contact?.name?.components?.find(c => c.kind === kind)?.value || "";
|
||||
// Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases.
|
||||
const findComponent = (...kinds: string[]) =>
|
||||
contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || "";
|
||||
|
||||
// Convert RFC 9553 AnniversaryDate to ISO date string for HTML date input
|
||||
function anniversaryDateToString(date: AnniversaryDate): string {
|
||||
@@ -210,11 +213,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
};
|
||||
}
|
||||
|
||||
const [prefix, setPrefix] = useState(findComponent("prefix"));
|
||||
const [prefix, setPrefix] = useState(findComponent("title", "prefix"));
|
||||
const [givenName, setGivenName] = useState(findComponent("given"));
|
||||
const [additionalName, setAdditionalName] = useState(findComponent("additional"));
|
||||
const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle"));
|
||||
const [surname, setSurname] = useState(findComponent("surname"));
|
||||
const [suffix, setSuffix] = useState(findComponent("suffix"));
|
||||
const [suffix, setSuffix] = useState(findComponent("generation", "suffix"));
|
||||
|
||||
const [nickname, setNickname] = useState(
|
||||
contact?.nicknames ? Object.values(contact.nicknames)[0]?.name || "" : ""
|
||||
@@ -328,8 +331,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
return ids[0];
|
||||
}
|
||||
}
|
||||
if (defaultAddressBookId && addressBooks?.some(b => b.id === defaultAddressBookId)) {
|
||||
return defaultAddressBookId;
|
||||
}
|
||||
return "";
|
||||
}, [contact]);
|
||||
}, [contact, defaultAddressBookId, addressBooks]);
|
||||
const [selectedBookId, setSelectedBookId] = useState(currentBookId);
|
||||
|
||||
const initialPhotoEntry = useMemo(() => {
|
||||
@@ -436,12 +442,13 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
|
||||
phonesMap[`p${i}`] = obj;
|
||||
});
|
||||
|
||||
// Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly.
|
||||
const nameComponents = [];
|
||||
if (prefix.trim()) nameComponents.push({ kind: "prefix" as const, value: prefix.trim() });
|
||||
if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() });
|
||||
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
|
||||
if (additionalName.trim()) nameComponents.push({ kind: "additional" as const, value: additionalName.trim() });
|
||||
if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() });
|
||||
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
|
||||
if (suffix.trim()) nameComponents.push({ kind: "suffix" as const, value: suffix.trim() });
|
||||
if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() });
|
||||
|
||||
const titlesMap: Record<string, { name: string; kind?: "title" | "role" }> = {};
|
||||
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
|
||||
|
||||
@@ -27,6 +27,9 @@ interface ContactsSidebarProps {
|
||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
|
||||
onRenameAddressBook?: (addressBook: AddressBook) => void;
|
||||
onShareAddressBook?: (addressBook: AddressBook) => void;
|
||||
onCreateContactInBook?: (addressBook: AddressBook) => void;
|
||||
onDeleteAddressBook?: (addressBook: AddressBook) => void;
|
||||
onRenameKeyword?: (keyword: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
@@ -62,6 +65,9 @@ export function ContactsSidebar({
|
||||
onDropContacts,
|
||||
onDropContactsToCategory,
|
||||
onRenameAddressBook,
|
||||
onShareAddressBook,
|
||||
onCreateContactInBook,
|
||||
onDeleteAddressBook,
|
||||
onRenameKeyword,
|
||||
className,
|
||||
}: ContactsSidebarProps) {
|
||||
@@ -288,7 +294,7 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -430,7 +436,7 @@ export function ContactsSidebar({
|
||||
contactCount={contactCountByBook[book.id] || 0}
|
||||
onSelect={() => onSelectCategory({ addressBookId: book.id })}
|
||||
onDropContacts={onDropContacts}
|
||||
onContextMenu={onRenameAddressBook ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -438,24 +444,65 @@ export function ContactsSidebar({
|
||||
</div>
|
||||
|
||||
{/* Address book context menu */}
|
||||
{bookContextMenu.data && onRenameAddressBook && (
|
||||
<ContextMenu
|
||||
ref={bookMenuRef}
|
||||
isOpen={bookContextMenu.isOpen}
|
||||
position={bookContextMenu.position}
|
||||
onClose={closeBookContextMenu}
|
||||
>
|
||||
<ContextMenuItem
|
||||
icon={Pencil}
|
||||
label={t("address_books.rename")}
|
||||
onClick={() => {
|
||||
const book = bookContextMenu.data!;
|
||||
closeBookContextMenu();
|
||||
onRenameAddressBook(book);
|
||||
}}
|
||||
/>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{bookContextMenu.data && (onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) && (() => {
|
||||
const book = bookContextMenu.data;
|
||||
const canCreate = onCreateContactInBook && book.myRights?.mayWrite !== false;
|
||||
const canRename = onRenameAddressBook && book.myRights?.mayWrite !== false;
|
||||
const canShare = onShareAddressBook && book.myRights?.mayShare && !book.isShared;
|
||||
const canDelete = onDeleteAddressBook && !book.isDefault && !book.isShared && book.myRights?.mayDelete !== false;
|
||||
const showSeparator = (canCreate || canRename || canShare) && canDelete;
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={bookMenuRef}
|
||||
isOpen={bookContextMenu.isOpen}
|
||||
position={bookContextMenu.position}
|
||||
onClose={closeBookContextMenu}
|
||||
>
|
||||
{canCreate && (
|
||||
<ContextMenuItem
|
||||
icon={UserPlus}
|
||||
label={t("address_books.new_contact_in_book")}
|
||||
onClick={() => {
|
||||
closeBookContextMenu();
|
||||
onCreateContactInBook(book);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{canRename && (
|
||||
<ContextMenuItem
|
||||
icon={Pencil}
|
||||
label={t("address_books.rename")}
|
||||
onClick={() => {
|
||||
closeBookContextMenu();
|
||||
onRenameAddressBook(book);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{canShare && (
|
||||
<ContextMenuItem
|
||||
icon={Users}
|
||||
label={t("address_books.share")}
|
||||
onClick={() => {
|
||||
closeBookContextMenu();
|
||||
onShareAddressBook(book);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showSeparator && <ContextMenuSeparator />}
|
||||
{canDelete && (
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={t("address_books.delete")}
|
||||
onClick={() => {
|
||||
closeBookContextMenu();
|
||||
onDeleteAddressBook(book);
|
||||
}}
|
||||
destructive
|
||||
/>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Keyword (category) context menu */}
|
||||
{keywordContextMenu.data && onRenameKeyword && (
|
||||
|
||||
@@ -1100,8 +1100,14 @@ export function EmailComposer({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full bg-background", className)}>
|
||||
<PluginSlot
|
||||
name="composer-sidebar"
|
||||
className="hidden md:flex shrink-0 h-full overflow-hidden border-r border-border"
|
||||
/>
|
||||
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
|
||||
<div
|
||||
className={cn("flex flex-col h-full bg-background relative", className)}
|
||||
className="flex flex-col h-full bg-background relative flex-1 min-w-0"
|
||||
data-tour="composer"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -1674,6 +1680,11 @@ export function EmailComposer({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PluginSlot
|
||||
name="composer-sidebar-right"
|
||||
className="hidden md:flex shrink-0 h-full overflow-hidden border-l border-border"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -288,7 +288,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
|
||||
</div>
|
||||
|
||||
{/* Third Line: Preview (controlled by showPreview setting) */}
|
||||
{showPreview && density !== 'extra-compact' && (
|
||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
isUnread
|
||||
|
||||
@@ -309,7 +309,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
{email.subject || "(no subject)"}
|
||||
</div>
|
||||
|
||||
{showPreview && density !== 'extra-compact' && (
|
||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
isUnread
|
||||
@@ -715,7 +715,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
{latestEmail.subject || "(no subject)"}
|
||||
</div>
|
||||
|
||||
{showPreview && density !== 'extra-compact' && (
|
||||
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
hasUnread
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import csMessages from '@/locales/cs/common.json';
|
||||
import enMessages from '@/locales/en/common.json';
|
||||
import frMessages from '@/locales/fr/common.json';
|
||||
import jaMessages from '@/locales/ja/common.json';
|
||||
@@ -20,6 +21,7 @@ import zhMessages from '@/locales/zh/common.json';
|
||||
|
||||
// Pre-loaded translations (loaded at build time, not runtime)
|
||||
const ALL_MESSAGES = {
|
||||
cs: csMessages,
|
||||
en: enMessages,
|
||||
fr: frMessages,
|
||||
ja: jaMessages,
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Book, Pencil, Share2, Tag } from "lucide-react";
|
||||
import { Book, Pencil, Share2, Tag, Users } from "lucide-react";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AddressBook } from "@/lib/jmap/types";
|
||||
import type { AddressBook, AddressBookRights } from "@/lib/jmap/types";
|
||||
import { ShareCollectionDialog } from "./share-collection-dialog";
|
||||
|
||||
function AddressBookEditRow({
|
||||
initial,
|
||||
@@ -70,9 +71,10 @@ export function AddressBookManagementSettings() {
|
||||
const tContacts = useTranslations("contacts");
|
||||
const tSettings = useTranslations("settings.contacts");
|
||||
const { client } = useAuthStore();
|
||||
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, renameKeyword } = useContactStore();
|
||||
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
|
||||
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -148,6 +150,16 @@ export function AddressBookManagementSettings() {
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{!book.isShared && book.myRights?.mayShare && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSharingId(book.id)}
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t("share")}
|
||||
>
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -242,6 +254,24 @@ export function AddressBookManagementSettings() {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
|
||||
{sharingId && client && (() => {
|
||||
const book = addressBooks.find((b) => b.id === sharingId);
|
||||
if (!book) return null;
|
||||
return (
|
||||
<ShareCollectionDialog
|
||||
client={client}
|
||||
kind="addressBook"
|
||||
collectionName={book.name}
|
||||
shareWith={book.shareWith}
|
||||
ownAccountId={client.getAccountId()}
|
||||
onShare={async (principalId, rights) => {
|
||||
await shareAddressBook(client, book, principalId, rights as AddressBookRights | null);
|
||||
}}
|
||||
onClose={() => setSharingId(null)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import { useAuthStore } from '@/stores/auth-store';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { SettingsSection } from './settings-section';
|
||||
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Copy, Link, Upload, Globe, RefreshCw, Eraser, Users } from 'lucide-react';
|
||||
import { ShareCollectionDialog } from './share-collection-dialog';
|
||||
import type { CalendarRights } from '@/lib/jmap/types';
|
||||
import { cn, formatDateTime } from '@/lib/utils';
|
||||
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
|
||||
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
|
||||
@@ -83,7 +85,7 @@ function CalendarColorPicker({
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarEditForm({
|
||||
export function CalendarEditForm({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
@@ -153,7 +155,7 @@ export { CalendarColorPicker, CALENDAR_COLORS };
|
||||
export function CalendarManagementSettings() {
|
||||
const t = useTranslations('calendar.management');
|
||||
const { client, serverUrl, username } = useAuthStore();
|
||||
const { calendars, updateCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
|
||||
const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
|
||||
|
||||
const [discoveredCalDavUrls, setDiscoveredCalDavUrls] = useState<Record<string, string | null>>({});
|
||||
const [wellKnownCalDavUrl, setWellKnownCalDavUrl] = useState<string | null>(null);
|
||||
@@ -164,6 +166,7 @@ export function CalendarManagementSettings() {
|
||||
const [clearingId, setClearingId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [colorPickerId, setColorPickerId] = useState<string | null>(null);
|
||||
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false);
|
||||
const [editingSubscription, setEditingSubscription] = useState<typeof icalSubscriptions[0] | null>(null);
|
||||
@@ -522,6 +525,16 @@ export function CalendarManagementSettings() {
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{cal.myRights?.mayShare && !cal.isShared && !isSubscriptionCalendar(cal.id) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSharingId(cal.id)}
|
||||
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t('share')}
|
||||
>
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setClearingId(cal.id)}
|
||||
@@ -689,6 +702,24 @@ export function CalendarManagementSettings() {
|
||||
onClose={() => setEditingSubscription(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sharingId && client && (() => {
|
||||
const cal = calendars.find((c) => c.id === sharingId);
|
||||
if (!cal) return null;
|
||||
return (
|
||||
<ShareCollectionDialog
|
||||
client={client}
|
||||
kind="calendar"
|
||||
collectionName={cal.name}
|
||||
shareWith={cal.shareWith}
|
||||
ownAccountId={client.getAccountId()}
|
||||
onShare={async (principalId, rights) => {
|
||||
await shareCalendar(client, cal.id, principalId, rights as CalendarRights | null);
|
||||
}}
|
||||
onClose={() => setSharingId(null)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "lucide-react";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { Principal, CalendarRights, AddressBookRights } from "@/lib/jmap/types";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
type ShareKind = "calendar" | "addressBook";
|
||||
type AnyRights = CalendarRights | AddressBookRights;
|
||||
|
||||
type RolePreset = "freeBusy" | "read" | "readWrite" | "manager" | "custom";
|
||||
|
||||
const CALENDAR_PRESETS: Record<Exclude<RolePreset, "custom">, CalendarRights> = {
|
||||
freeBusy: {
|
||||
mayReadFreeBusy: true, mayReadItems: false, mayWriteAll: false, mayWriteOwn: false,
|
||||
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
|
||||
},
|
||||
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<Exclude<RolePreset, "custom" | "freeBusy">, 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 },
|
||||
};
|
||||
|
||||
function detectCalendarPreset(r: CalendarRights): RolePreset {
|
||||
for (const [name, preset] of Object.entries(CALENDAR_PRESETS) as [Exclude<RolePreset, "custom">, CalendarRights][]) {
|
||||
if ((Object.keys(preset) as (keyof CalendarRights)[]).every((k) => preset[k] === r[k])) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
function detectAddressBookPreset(r: AddressBookRights): RolePreset {
|
||||
for (const [name, preset] of Object.entries(ADDRESS_BOOK_PRESETS) as [Exclude<RolePreset, "custom" | "freeBusy">, AddressBookRights][]) {
|
||||
const keys = Object.keys(preset) as (keyof AddressBookRights)[];
|
||||
if (keys.every((k) => preset[k] === (r[k] ?? false))) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
interface ShareCollectionDialogProps {
|
||||
client: IJMAPClient;
|
||||
kind: ShareKind;
|
||||
collectionName: string;
|
||||
shareWith: Record<string, AnyRights> | null | undefined;
|
||||
ownAccountId: string;
|
||||
onShare: (principalId: string, rights: AnyRights | null) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ShareCollectionDialog({
|
||||
client,
|
||||
kind,
|
||||
collectionName,
|
||||
shareWith,
|
||||
ownAccountId,
|
||||
onShare,
|
||||
onClose,
|
||||
}: ShareCollectionDialogProps) {
|
||||
const t = useTranslations("sharing");
|
||||
const tCommon = useTranslations("common");
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [principals, setPrincipals] = useState<Principal[]>([]);
|
||||
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
|
||||
// Load principals on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingPrincipals(true);
|
||||
client.getPrincipals().then((list) => {
|
||||
if (cancelled) return;
|
||||
// Exclude the user themselves and any principal that already has a share
|
||||
const existing = new Set(Object.keys(shareWith || {}));
|
||||
const filtered = list.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
|
||||
setPrincipals(filtered);
|
||||
setLoadingPrincipals(false);
|
||||
}).catch(() => {
|
||||
if (!cancelled) setLoadingPrincipals(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [client, ownAccountId, shareWith]);
|
||||
|
||||
// Map principal id -> Principal for displayed shares
|
||||
const allPrincipalsById = useMemo(() => {
|
||||
const map = new Map<string, Principal>();
|
||||
for (const p of principals) map.set(p.id, p);
|
||||
return map;
|
||||
}, [principals]);
|
||||
|
||||
// Close on Escape, focus trap, click outside
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleSetRights = async (principalId: string, preset: RolePreset) => {
|
||||
if (preset === "custom") return; // custom is read-only here
|
||||
const rights = kind === "calendar"
|
||||
? CALENDAR_PRESETS[preset as keyof typeof CALENDAR_PRESETS]
|
||||
: ADDRESS_BOOK_PRESETS[preset as keyof typeof ADDRESS_BOOK_PRESETS];
|
||||
if (!rights) return;
|
||||
setSavingId(principalId);
|
||||
try {
|
||||
await onShare(principalId, rights);
|
||||
toast.success(t("share_updated"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t("share_failed"));
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (principalId: string) => {
|
||||
setSavingId(principalId);
|
||||
try {
|
||||
await onShare(principalId, null);
|
||||
toast.success(t("share_removed"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t("share_failed"));
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async (principal: Principal) => {
|
||||
const defaultPreset: RolePreset = "read";
|
||||
const rights = kind === "calendar"
|
||||
? CALENDAR_PRESETS[defaultPreset]
|
||||
: ADDRESS_BOOK_PRESETS[defaultPreset];
|
||||
setSavingId(principal.id);
|
||||
try {
|
||||
await onShare(principal.id, rights);
|
||||
// Move principal out of the "to add" list
|
||||
setPrincipals((prev) => prev.filter((p) => p.id !== principal.id));
|
||||
setShowAdd(false);
|
||||
setSearch("");
|
||||
toast.success(t("share_added"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t("share_failed"));
|
||||
} 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 sharedEntries = useMemo(() => {
|
||||
return Object.entries(shareWith || {}) as [string, AnyRights][];
|
||||
}, [shareWith]);
|
||||
|
||||
const presetOptions = kind === "calendar"
|
||||
? ["freeBusy", "read", "readWrite", "manager"] as const
|
||||
: ["read", "readWrite", "manager"] as const;
|
||||
|
||||
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: collectionName })}
|
||||
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" />
|
||||
<h2 className="text-lg font-semibold">{t("title", { name: collectionName })}</h2>
|
||||
</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>
|
||||
|
||||
{sharedEntries.length === 0 && !showAdd && (
|
||||
<div className="text-sm text-muted-foreground italic py-4 text-center">
|
||||
{t("no_shares")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sharedEntries.length > 0 && (
|
||||
<ul className="divide-y divide-border rounded-md border border-border overflow-hidden">
|
||||
{sharedEntries.map(([principalId, rights]) => {
|
||||
const principal = allPrincipalsById.get(principalId);
|
||||
const preset = kind === "calendar"
|
||||
? detectCalendarPreset(rights as CalendarRights)
|
||||
: detectAddressBookPreset(rights as AddressBookRights);
|
||||
return (
|
||||
<li key={principalId} className="flex items-center gap-3 px-3 py-2.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{principal?.name || principal?.email || principalId}
|
||||
</div>
|
||||
{principal?.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{principal.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={preset}
|
||||
onChange={(e) => handleSetRights(principalId, e.target.value as RolePreset)}
|
||||
disabled={savingId === principalId}
|
||||
className="appearance-none rounded-md border border-input bg-background pl-3 pr-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>
|
||||
))}
|
||||
{preset === "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(principalId)}
|
||||
disabled={savingId === 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 === 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 mr-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 mr-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-left px-3 py-2 rounded-md hover:bg-muted disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => { setShowAdd(false); setSearch(""); }}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils';
|
||||
import { flagComponents } from './flag-icons';
|
||||
|
||||
const languages = [
|
||||
{ value: 'cs', label: 'Česky' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
|
||||
@@ -8,6 +8,11 @@ services:
|
||||
- PORT=3000
|
||||
env_file:
|
||||
- .env.local
|
||||
volumes:
|
||||
# Encrypted user settings (SETTINGS_DATA_DIR).
|
||||
- bulwark-settings:/app/data/settings
|
||||
# Admin dashboard state: config, password hash, plugins, audit logs (ADMIN_DATA_DIR).
|
||||
- bulwark-admin:/app/data/admin
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
@@ -23,3 +28,7 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
bulwark-settings:
|
||||
bulwark-admin:
|
||||
|
||||
@@ -80,6 +80,7 @@ export function useTimeGridInteractions({
|
||||
dayDate: Date,
|
||||
) => {
|
||||
if (isMobile) return;
|
||||
if (e.pointerType === "touch") return;
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest("[data-calendar-event], [data-resize-handle]")) return;
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
// Use static imports for better compatibility
|
||||
let messages;
|
||||
switch (locale) {
|
||||
case 'cs':
|
||||
messages = (await import('../locales/cs/common.json')).default;
|
||||
break;
|
||||
case 'fr':
|
||||
messages = (await import('../locales/fr/common.json')).default;
|
||||
break;
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
|
||||
| 'as-needed';
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ['en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'uk', 'zh'],
|
||||
locales: ['cs', 'en', 'fr', 'de', 'es', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'uk', 'zh'],
|
||||
defaultLocale: 'en',
|
||||
localePrefix
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@ function makeCalendar(overrides: Partial<Calendar> = {}): Calendar {
|
||||
mayWriteOwn: true,
|
||||
mayUpdatePrivate: true,
|
||||
mayRSVP: true,
|
||||
mayAdmin: false,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
},
|
||||
...overrides,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isValidFrameOrigin,
|
||||
sanitizeFrameOrigins,
|
||||
} from '@/lib/admin/csp-frame-origins';
|
||||
|
||||
describe('isValidFrameOrigin', () => {
|
||||
it('accepts plain https origins', () => {
|
||||
expect(isValidFrameOrigin('https://www.youtube-nocookie.com')).toBe(true);
|
||||
expect(isValidFrameOrigin('https://meet.example.com')).toBe(true);
|
||||
expect(isValidFrameOrigin('https://a.b.c.example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a wildcard subdomain', () => {
|
||||
expect(isValidFrameOrigin('https://*.example.com')).toBe(true);
|
||||
expect(isValidFrameOrigin('https://*.youtube.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an explicit port', () => {
|
||||
expect(isValidFrameOrigin('https://meet.example.com:8443')).toBe(true);
|
||||
expect(isValidFrameOrigin('https://*.example.com:443')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-https schemes', () => {
|
||||
expect(isValidFrameOrigin('http://example.com')).toBe(false);
|
||||
expect(isValidFrameOrigin('ftp://example.com')).toBe(false);
|
||||
expect(isValidFrameOrigin('data:text/html,foo')).toBe(false);
|
||||
expect(isValidFrameOrigin('javascript:alert(1)')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects bare schemes and wildcard hosts', () => {
|
||||
expect(isValidFrameOrigin('https://')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://*')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://*.com')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://localhost')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects paths, queries, and fragments', () => {
|
||||
expect(isValidFrameOrigin('https://example.com/embed')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://example.com/')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://example.com?x=1')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://example.com#x')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects userinfo, IPs, and IPv6', () => {
|
||||
expect(isValidFrameOrigin('https://user:pass@example.com')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://1.2.3.4')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://[::1]')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects values that try to break out of the directive', () => {
|
||||
expect(isValidFrameOrigin("https://example.com'; script-src 'unsafe-eval")).toBe(false);
|
||||
expect(isValidFrameOrigin('https://example.com" data:')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://example.com data:')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://example.com\nhttps://evil.com')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://example.com;https://evil.com')).toBe(false);
|
||||
expect(isValidFrameOrigin('https://exa,mple.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-strings and obvious garbage', () => {
|
||||
expect(isValidFrameOrigin(undefined)).toBe(false);
|
||||
expect(isValidFrameOrigin(null)).toBe(false);
|
||||
expect(isValidFrameOrigin(42)).toBe(false);
|
||||
expect(isValidFrameOrigin('')).toBe(false);
|
||||
expect(isValidFrameOrigin('not-a-url')).toBe(false);
|
||||
expect(isValidFrameOrigin('a'.repeat(300))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeFrameOrigins', () => {
|
||||
it('returns empty for non-array input', () => {
|
||||
expect(sanitizeFrameOrigins(undefined)).toEqual([]);
|
||||
expect(sanitizeFrameOrigins(null)).toEqual([]);
|
||||
expect(sanitizeFrameOrigins('https://example.com')).toEqual([]);
|
||||
expect(sanitizeFrameOrigins({})).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps valid entries and drops invalid ones silently', () => {
|
||||
expect(
|
||||
sanitizeFrameOrigins([
|
||||
'https://www.youtube-nocookie.com',
|
||||
'http://insecure.com',
|
||||
'https://meet.example.com:8443',
|
||||
'https://example.com/path',
|
||||
42,
|
||||
'https://*.vimeo.com',
|
||||
]),
|
||||
).toEqual([
|
||||
'https://www.youtube-nocookie.com',
|
||||
'https://meet.example.com:8443',
|
||||
'https://*.vimeo.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('dedupes case-insensitively', () => {
|
||||
expect(
|
||||
sanitizeFrameOrigins([
|
||||
'https://Example.com',
|
||||
'https://example.com',
|
||||
'https://EXAMPLE.com',
|
||||
]),
|
||||
).toEqual(['https://Example.com']);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,8 @@ function resetStore() {
|
||||
'email-banner': [],
|
||||
'email-footer': [],
|
||||
'composer-toolbar': [],
|
||||
'composer-sidebar': [],
|
||||
'composer-sidebar-right': [],
|
||||
'sidebar-widget': [],
|
||||
'email-detail-sidebar': [],
|
||||
'settings-section': [],
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const lookup = vi.fn();
|
||||
|
||||
vi.mock('node:dns/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:dns/promises')>();
|
||||
return {
|
||||
...actual,
|
||||
default: { ...actual, lookup: (...args: unknown[]) => lookup(...args) },
|
||||
lookup: (...args: unknown[]) => lookup(...args),
|
||||
};
|
||||
});
|
||||
|
||||
describe('isPublicHttpUrl', () => {
|
||||
beforeEach(() => {
|
||||
lookup.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
const mod = await import('@/lib/security/url-guard');
|
||||
return mod.isPublicHttpUrl;
|
||||
}
|
||||
|
||||
it('accepts public https URLs whose DNS resolves to a public address', async () => {
|
||||
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
const isPublicHttpUrl = await load();
|
||||
expect(await isPublicHttpUrl('https://example.com/jmap')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects malformed URLs', async () => {
|
||||
const isPublicHttpUrl = await load();
|
||||
expect(await isPublicHttpUrl('not a url')).toBe(false);
|
||||
expect(await isPublicHttpUrl('')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-http(s) protocols', async () => {
|
||||
const isPublicHttpUrl = await load();
|
||||
expect(await isPublicHttpUrl('file:///etc/passwd')).toBe(false);
|
||||
expect(await isPublicHttpUrl('gopher://example.com/')).toBe(false);
|
||||
expect(await isPublicHttpUrl('javascript:alert(1)')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects URLs with embedded credentials', async () => {
|
||||
const isPublicHttpUrl = await load();
|
||||
expect(await isPublicHttpUrl('https://user:pass@example.com/')).toBe(false);
|
||||
expect(await isPublicHttpUrl('https://user@example.com/')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects loopback hostnames without DNS', async () => {
|
||||
const isPublicHttpUrl = await load();
|
||||
expect(await isPublicHttpUrl('http://localhost/')).toBe(false);
|
||||
expect(await isPublicHttpUrl('http://service.localhost/')).toBe(false);
|
||||
expect(await isPublicHttpUrl('http://server.local/')).toBe(false);
|
||||
expect(await isPublicHttpUrl('http://kube.internal/api')).toBe(false);
|
||||
expect(await isPublicHttpUrl('http://1.0.0.127.in-addr.arpa/')).toBe(false);
|
||||
expect(lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects literal IPv4 loopback and RFC-1918 ranges', async () => {
|
||||
const isPublicHttpUrl = await load();
|
||||
expect(await isPublicHttpUrl('http://127.0.0.1/')).toBe(false);
|
||||
expect(await isPublicHttpUrl('http://10.0.0.5/')).toBe(false);
|
||||
expect(await isPublicHttpUrl('http://10.255.255.255/')).toBe(false);
|
||||
expect(await isPublicHttpUrl('http://172.16.0.1/')).toBe(false);
|
||||
expect(await isPublicHttpUrl('http://172.31.255.254/')).toBe(false);
|
||||
expect(await isPublicHttpUrl('http://192.168.1.1/')).toBe(false);
|
||||
expect(await isPublicHttpUrl('http://0.0.0.0/')).toBe(false);
|
||||
expect(lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects literal AWS / GCP / Azure metadata IP', async () => {
|
||||
const isPublicHttpUrl = await load();
|
||||
expect(await isPublicHttpUrl('http://169.254.169.254/latest/meta-data/')).toBe(false);
|
||||
expect(await isPublicHttpUrl('http://169.254.0.1/')).toBe(false);
|
||||
expect(lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects IPv6 loopback, ULA, and link-local literals', async () => {
|
||||
const isPublicHttpUrl = await load();
|
||||
expect(await isPublicHttpUrl('http://[::1]/')).toBe(false);
|
||||
expect(await isPublicHttpUrl('http://[::]/')).toBe(false);
|
||||
expect(await isPublicHttpUrl('http://[fc00::1]/')).toBe(false);
|
||||
expect(await isPublicHttpUrl('http://[fd12:3456::1]/')).toBe(false);
|
||||
expect(await isPublicHttpUrl('http://[fe80::1]/')).toBe(false);
|
||||
expect(lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects when DNS resolves to a private address (rebinding)', async () => {
|
||||
lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
|
||||
const isPublicHttpUrl = await load();
|
||||
expect(await isPublicHttpUrl('https://evil.example.com/')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when any resolved address is private (mixed)', async () => {
|
||||
lookup.mockResolvedValue([
|
||||
{ address: '93.184.216.34', family: 4 },
|
||||
{ address: '10.0.0.1', family: 4 },
|
||||
]);
|
||||
const isPublicHttpUrl = await load();
|
||||
expect(await isPublicHttpUrl('https://mixed.example.com/')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when DNS resolves to IPv6 loopback', async () => {
|
||||
lookup.mockResolvedValue([{ address: '::1', family: 6 }]);
|
||||
const isPublicHttpUrl = await load();
|
||||
expect(await isPublicHttpUrl('https://evil6.example.com/')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when DNS lookup throws', async () => {
|
||||
lookup.mockRejectedValue(new Error('ENOTFOUND'));
|
||||
const isPublicHttpUrl = await load();
|
||||
expect(await isPublicHttpUrl('https://nonexistent.example.com/')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when DNS returns no records', async () => {
|
||||
lookup.mockResolvedValue([]);
|
||||
const isPublicHttpUrl = await load();
|
||||
expect(await isPublicHttpUrl('https://empty.example.com/')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -34,11 +34,11 @@ describe("parseVCard", () => {
|
||||
expect(result).toHaveLength(1);
|
||||
const components = result[0].name?.components || [];
|
||||
expect(components).toEqual([
|
||||
{ kind: "prefix", value: "Mr." },
|
||||
{ kind: "title", value: "Mr." },
|
||||
{ kind: "given", value: "John" },
|
||||
{ kind: "additional", value: "Michael" },
|
||||
{ kind: "given2", value: "Michael" },
|
||||
{ kind: "surname", value: "Doe" },
|
||||
{ kind: "suffix", value: "Jr." },
|
||||
{ kind: "generation", value: "Jr." },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -52,6 +52,21 @@ describe("parseVCard", () => {
|
||||
expect(components.find((c) => c.kind === "surname")?.value).toBe("Doe");
|
||||
});
|
||||
|
||||
it("maps prefix and middle name to RFC 9553 standard kinds (issue #224)", () => {
|
||||
// N: family;given;additional;prefix;suffix (RFC 6350 order)
|
||||
const withPrefix = parseVCard(`BEGIN:VCARD\r\nVERSION:3.0\r\nN:Smith;John;;Mr.;\r\nEMAIL:j@example.com\r\nEND:VCARD`);
|
||||
const c1 = withPrefix[0].name?.components || [];
|
||||
expect(c1.find((c) => c.kind === "surname")?.value).toBe("Smith");
|
||||
expect(c1.find((c) => c.kind === "given")?.value).toBe("John");
|
||||
expect(c1.find((c) => c.kind === "title")?.value).toBe("Mr.");
|
||||
|
||||
const withMiddle = parseVCard(`BEGIN:VCARD\r\nVERSION:3.0\r\nN:Smith;John;Mike;;\r\nEMAIL:j@example.com\r\nEND:VCARD`);
|
||||
const c2 = withMiddle[0].name?.components || [];
|
||||
expect(c2.find((c) => c.kind === "surname")?.value).toBe("Smith");
|
||||
expect(c2.find((c) => c.kind === "given")?.value).toBe("John");
|
||||
expect(c2.find((c) => c.kind === "given2")?.value).toBe("Mike");
|
||||
});
|
||||
|
||||
it("parses vCard with phone, org, and address", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
@@ -204,6 +219,60 @@ describe("parseVCard", () => {
|
||||
expect(result[0].kind).toBe("group");
|
||||
});
|
||||
|
||||
it("decodes ENCODING=QUOTED-PRINTABLE values with UTF-8 charset", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:2.1",
|
||||
"N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:M=C3=BCller;Hans;;;",
|
||||
"FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Hans M=C3=BCller",
|
||||
"NOTE;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Caf=C3=A9 stra=C3=9Fe",
|
||||
"EMAIL:hans@example.com",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
expect(result).toHaveLength(1);
|
||||
const card = result[0];
|
||||
|
||||
const components = card.name?.components || [];
|
||||
expect(components.find((c) => c.kind === "given")?.value).toBe("Hans");
|
||||
expect(components.find((c) => c.kind === "surname")?.value).toBe("Müller");
|
||||
expect(card.notes?.n0?.note).toBe("Café straße");
|
||||
});
|
||||
|
||||
it("joins QUOTED-PRINTABLE soft line breaks (= at end of line)", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:2.1",
|
||||
"FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Hans=20J=",
|
||||
"=C3=BCrgen=20M=C3=BCller",
|
||||
"EMAIL:hj@example.com",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
expect(result).toHaveLength(1);
|
||||
const components = result[0].name?.components || [];
|
||||
const given = components.find((c) => c.kind === "given")?.value;
|
||||
const surname = components.find((c) => c.kind === "surname")?.value;
|
||||
expect(given).toBe("Hans");
|
||||
expect(surname).toBe("Jürgen Müller");
|
||||
});
|
||||
|
||||
it("recognizes bare QUOTED-PRINTABLE encoding parameter (vCard 2.1 style)", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:2.1",
|
||||
"FN;QUOTED-PRINTABLE;CHARSET=UTF-8:Caf=C3=A9",
|
||||
"EMAIL:c@example.com",
|
||||
"END:VCARD",
|
||||
].join("\r\n");
|
||||
|
||||
const result = parseVCard(vcf);
|
||||
const components = result[0].name?.components || [];
|
||||
expect(components.find((c) => c.kind === "given")?.value).toBe("Café");
|
||||
});
|
||||
|
||||
it("parses GENDER, LOGO, SOUND, LABEL, CALURI, CALADRURI, FBURL, SOURCE", () => {
|
||||
const vcf = [
|
||||
"BEGIN:VCARD",
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const lookup = vi.fn();
|
||||
|
||||
vi.mock('node:dns/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:dns/promises')>();
|
||||
return {
|
||||
...actual,
|
||||
default: { ...actual, lookup: (...args: unknown[]) => lookup(...args) },
|
||||
lookup: (...args: unknown[]) => lookup(...args),
|
||||
};
|
||||
});
|
||||
|
||||
describe('verifyJmapAuth SSRF protection', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
lookup.mockReset();
|
||||
vi.resetModules();
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
const mod = await import('@/lib/auth/verify-jmap-auth');
|
||||
return mod;
|
||||
}
|
||||
|
||||
it('rejects loopback literal without issuing fetch', async () => {
|
||||
const { verifyJmapAuth, JmapAuthVerificationError } = await load();
|
||||
await expect(verifyJmapAuth('http://127.0.0.1', 'Bearer x')).rejects.toBeInstanceOf(
|
||||
JmapAuthVerificationError,
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects AWS IMDS endpoint without issuing fetch', async () => {
|
||||
const { verifyJmapAuth } = await load();
|
||||
await expect(
|
||||
verifyJmapAuth('http://169.254.169.254', 'Bearer x'),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects RFC-1918 literals without issuing fetch', async () => {
|
||||
const { verifyJmapAuth } = await load();
|
||||
for (const target of ['http://10.0.0.5', 'http://172.16.0.1', 'http://192.168.1.1']) {
|
||||
await expect(verifyJmapAuth(target, 'Bearer x')).rejects.toMatchObject({ status: 400 });
|
||||
}
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects localhost hostname without issuing fetch', async () => {
|
||||
const { verifyJmapAuth } = await load();
|
||||
await expect(verifyJmapAuth('http://localhost', 'Bearer x')).rejects.toMatchObject({
|
||||
status: 400,
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects IPv6 loopback literal without issuing fetch', async () => {
|
||||
const { verifyJmapAuth } = await load();
|
||||
await expect(verifyJmapAuth('http://[::1]', 'Bearer x')).rejects.toMatchObject({
|
||||
status: 400,
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects hostnames whose DNS resolves to a private IP without issuing fetch', async () => {
|
||||
lookup.mockResolvedValue([{ address: '10.0.0.1', family: 4 }]);
|
||||
const { verifyJmapAuth } = await load();
|
||||
await expect(verifyJmapAuth('https://internal.example.com', 'Bearer x')).rejects.toMatchObject({
|
||||
status: 400,
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects file:// URLs', async () => {
|
||||
const { verifyJmapAuth } = await load();
|
||||
await expect(verifyJmapAuth('file:///etc/passwd', 'Bearer x')).rejects.toMatchObject({
|
||||
status: 400,
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to follow a redirect to a private address', async () => {
|
||||
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(null, { status: 302, headers: { location: 'http://127.0.0.1/.well-known/jmap' } }),
|
||||
);
|
||||
const { verifyJmapAuth } = await load();
|
||||
await expect(verifyJmapAuth('https://example.com', 'Bearer x')).rejects.toMatchObject({
|
||||
status: 400,
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refuses to follow a redirect to AWS IMDS', async () => {
|
||||
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: 'http://169.254.169.254/latest/meta-data/' },
|
||||
}),
|
||||
);
|
||||
const { verifyJmapAuth } = await load();
|
||||
await expect(verifyJmapAuth('https://example.com', 'Bearer x')).rejects.toMatchObject({
|
||||
status: 400,
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('accepts a public host that returns a valid JMAP session', async () => {
|
||||
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ apiUrl: 'https://example.com/api', accounts: {} }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const { verifyJmapAuth } = await load();
|
||||
await expect(verifyJmapAuth('https://example.com', 'Bearer x')).resolves.toBe(
|
||||
'https://example.com',
|
||||
);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://example.com/.well-known/jmap',
|
||||
expect.objectContaining({ redirect: 'manual' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an invalid Authorization header before any fetch', async () => {
|
||||
const { verifyJmapAuth } = await load();
|
||||
await expect(verifyJmapAuth('https://example.com', 'NotAuth')).rejects.toMatchObject({
|
||||
status: 400,
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { CONFIG_ENV_MAP, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
|
||||
|
||||
function getAdminDir(): string {
|
||||
@@ -64,6 +65,12 @@ class ConfigManager {
|
||||
if (envVal !== undefined) {
|
||||
return parseEnvValue(envVal, mapping.type) as T;
|
||||
}
|
||||
if (mapping.fileEnvVar) {
|
||||
const fileVal = readFileEnv(process.env[mapping.fileEnvVar]);
|
||||
if (fileVal !== null) {
|
||||
return parseEnvValue(fileVal, mapping.type) as T;
|
||||
}
|
||||
}
|
||||
if (defaultValue !== undefined) return defaultValue;
|
||||
return mapping.defaultValue as T;
|
||||
}
|
||||
@@ -94,9 +101,16 @@ class ConfigManager {
|
||||
const envVal = process.env[mapping.envVar];
|
||||
if (envVal !== undefined) {
|
||||
result[key] = { value: parseEnvValue(envVal, mapping.type), source: 'env' };
|
||||
} else {
|
||||
result[key] = { value: mapping.defaultValue, source: 'default' };
|
||||
continue;
|
||||
}
|
||||
if (mapping.fileEnvVar) {
|
||||
const fileVal = readFileEnv(process.env[mapping.fileEnvVar]);
|
||||
if (fileVal !== null) {
|
||||
result[key] = { value: parseEnvValue(fileVal, mapping.type), source: 'env' };
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result[key] = { value: mapping.defaultValue, source: 'default' };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Computes the union of CSP `frame-src` origins declared by installed and
|
||||
* enabled plugins. The proxy reads this on each request so that plugins can
|
||||
* embed external content (YouTube, Vimeo, Jitsi, …) without us hard-coding
|
||||
* domains in the host CSP.
|
||||
*
|
||||
* Origins are validated at install time and re-validated here as defense in
|
||||
* depth — any malformed value is dropped so a corrupted registry can never
|
||||
* inject arbitrary CSP fragments.
|
||||
*/
|
||||
|
||||
import { getPluginRegistry } from './plugin-registry';
|
||||
|
||||
// `https://host`, `https://host:port`, or `https://*.host[:port]`
|
||||
//
|
||||
// Each label is alphanumeric with optional inner dashes; the final TLD label
|
||||
// MUST start with a letter so we reject raw IPv4 literals.
|
||||
//
|
||||
// Disallowed by the regex (intentionally):
|
||||
// - any scheme other than https
|
||||
// - paths, queries, fragments
|
||||
// - userinfo, IPv4 literals, IPv6 literals (`[::1]`)
|
||||
// - bare wildcards (`https://*`)
|
||||
const FRAME_ORIGIN_RE =
|
||||
/^https:\/\/(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?))*\.(?:[a-z](?:[a-z0-9-]*[a-z0-9])?)(?::[0-9]{1,5})?$/i;
|
||||
|
||||
export function isValidFrameOrigin(origin: unknown): origin is string {
|
||||
if (typeof origin !== 'string') return false;
|
||||
if (origin.length > 200) return false;
|
||||
if (!FRAME_ORIGIN_RE.test(origin)) return false;
|
||||
// Reject control characters / whitespace as a final safeguard against
|
||||
// anything that would let an attacker break out of the directive.
|
||||
if (/[\s'"`;,()]/.test(origin)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitises a list of candidate origins from a manifest. Drops invalid
|
||||
* entries silently and dedupes (case-insensitive on the host).
|
||||
*/
|
||||
export function sanitizeFrameOrigins(input: unknown): string[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const value of input) {
|
||||
if (!isValidFrameOrigin(value)) continue;
|
||||
const key = value.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// In-memory cache. The proxy fires on every page navigation; reading the
|
||||
// registry JSON every time is fine but cheap to skip when nothing has
|
||||
// changed. Five seconds is short enough to make plugin install/uninstall
|
||||
// feel snappy without measurable overhead.
|
||||
let cachedAt = 0;
|
||||
let cachedOrigins: string[] = [];
|
||||
const CACHE_TTL_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Returns the union of frame origins declared by every enabled plugin in
|
||||
* the server-side registry, deduped and validated.
|
||||
*
|
||||
* Returns an empty array on any failure (missing file, parse error, …) so
|
||||
* a broken registry only ever shrinks the CSP — never widens it.
|
||||
*/
|
||||
export async function getEnabledPluginFrameOrigins(): Promise<string[]> {
|
||||
const now = Date.now();
|
||||
if (now - cachedAt < CACHE_TTL_MS) return cachedOrigins;
|
||||
|
||||
try {
|
||||
const registry = await getPluginRegistry();
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const plugin of registry.plugins) {
|
||||
if (!plugin.enabled) continue;
|
||||
const origins = sanitizeFrameOrigins(plugin.frameOrigins);
|
||||
for (const o of origins) {
|
||||
const key = o.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(o);
|
||||
}
|
||||
}
|
||||
cachedOrigins = out;
|
||||
cachedAt = now;
|
||||
return out;
|
||||
} catch {
|
||||
cachedOrigins = [];
|
||||
cachedAt = now;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Force the next call to re-read the registry. Used by install/uninstall. */
|
||||
export function invalidateFrameOriginsCache(): void {
|
||||
cachedAt = 0;
|
||||
cachedOrigins = [];
|
||||
}
|
||||
@@ -41,6 +41,11 @@ export interface ServerPlugin {
|
||||
configSchema?: Record<string, PluginConfigField>;
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
/**
|
||||
* Validated CSP origins (https-only, single-origin form) the plugin may
|
||||
* embed. Merged into the host frame-src by the proxy.
|
||||
*/
|
||||
frameOrigins?: string[];
|
||||
}
|
||||
|
||||
export interface ServerTheme {
|
||||
|
||||
+3
-3
@@ -106,7 +106,7 @@ export interface AuditEntry {
|
||||
}
|
||||
|
||||
/** Config keys that map to environment variables */
|
||||
export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | 'boolean' | 'url' | 'enum'; defaultValue: unknown; enumValues?: string[] }> = {
|
||||
export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: string; type: 'string' | 'boolean' | 'url' | 'enum'; defaultValue: unknown; enumValues?: string[] }> = {
|
||||
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
|
||||
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
|
||||
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
|
||||
@@ -124,7 +124,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | '
|
||||
oauthEnabled: { envVar: 'OAUTH_ENABLED', type: 'boolean', defaultValue: false },
|
||||
oauthOnly: { envVar: 'OAUTH_ONLY', type: 'boolean', defaultValue: false },
|
||||
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
|
||||
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', type: 'string', defaultValue: '' },
|
||||
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', fileEnvVar: 'OAUTH_CLIENT_SECRET_FILE', type: 'string', defaultValue: '' },
|
||||
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
|
||||
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
|
||||
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
|
||||
@@ -134,7 +134,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; type: 'string' | '
|
||||
settingsSyncEnabled: { envVar: 'SETTINGS_SYNC_ENABLED', type: 'boolean', defaultValue: false },
|
||||
logFormat: { envVar: 'LOG_FORMAT', type: 'enum', defaultValue: 'text', enumValues: ['text', 'json'] },
|
||||
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
|
||||
sessionSecret: { envVar: 'SESSION_SECRET', type: 'string', defaultValue: '' },
|
||||
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', type: 'string', defaultValue: '' },
|
||||
};
|
||||
|
||||
/** Keys that should never be exposed to the client config endpoint */
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
|
||||
const VERIFY_TIMEOUT_MS = 10000;
|
||||
const MAX_REDIRECTS = 3;
|
||||
|
||||
export class JmapAuthVerificationError extends Error {
|
||||
status: number;
|
||||
@@ -41,15 +44,47 @@ export async function verifyJmapAuth(serverUrl: string, authHeader: string): Pro
|
||||
const normalizedServerUrl = normalizeJmapServerUrl(serverUrl);
|
||||
validateProxyAuthHeader(authHeader);
|
||||
|
||||
if (!(await isPublicHttpUrl(normalizedServerUrl))) {
|
||||
throw new JmapAuthVerificationError('Server URL is not allowed', 400);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${normalizedServerUrl}/.well-known/jmap`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: authHeader },
|
||||
signal: controller.signal,
|
||||
});
|
||||
let currentUrl = `${normalizedServerUrl}/.well-known/jmap`;
|
||||
let response: Response | undefined;
|
||||
|
||||
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||
if (!(await isPublicHttpUrl(currentUrl))) {
|
||||
throw new JmapAuthVerificationError('Server URL is not allowed', 400);
|
||||
}
|
||||
|
||||
response = await fetch(currentUrl, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: authHeader },
|
||||
signal: controller.signal,
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('location');
|
||||
if (!location) {
|
||||
throw new JmapAuthVerificationError('Failed to verify JMAP session', 502);
|
||||
}
|
||||
currentUrl = new URL(location, currentUrl).toString();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
throw new JmapAuthVerificationError('Failed to verify JMAP session', 502);
|
||||
}
|
||||
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
throw new JmapAuthVerificationError('Too many redirects verifying JMAP session', 502);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new JmapAuthVerificationError(
|
||||
@@ -77,4 +112,4 @@ export async function verifyJmapAuth(serverUrl: string, authHeader: string): Pro
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function createBirthdayCalendar(name?: string, color?: string): Calendar
|
||||
mayWriteOwn: false,
|
||||
mayUpdatePrivate: false,
|
||||
mayRSVP: false,
|
||||
mayAdmin: false,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
},
|
||||
};
|
||||
|
||||
+10
-1
@@ -78,6 +78,10 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
supportsCalendars(): boolean { return true; }
|
||||
supportsSieve(): boolean { return true; }
|
||||
supportsFiles(): boolean { return true; }
|
||||
supportsPrincipals(): boolean { return false; }
|
||||
async getPrincipals(): Promise<never[]> { return []; }
|
||||
async setCalendarShare(): Promise<void> { /* demo: no-op */ }
|
||||
async setAddressBookShare(): Promise<void> { /* demo: no-op */ }
|
||||
|
||||
// ── Push / state ──────────────────────────────────────────────
|
||||
|
||||
@@ -552,6 +556,11 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
if (book) Object.assign(book, updates);
|
||||
}
|
||||
|
||||
async deleteAddressBook(addressBookId: string): Promise<void> {
|
||||
this.data.addressBooks = this.data.addressBooks.filter(b => b.id !== addressBookId);
|
||||
this.data.contacts = this.data.contacts.filter(c => !c.addressBookIds?.[addressBookId]);
|
||||
}
|
||||
|
||||
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
||||
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
|
||||
return [...this.data.contacts];
|
||||
@@ -609,7 +618,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
includeInAvailability: 'all',
|
||||
defaultAlertsWithTime: null, defaultAlertsWithoutTime: null,
|
||||
timeZone: null, shareWith: null,
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
|
||||
...calendar,
|
||||
} as Calendar;
|
||||
this.data.calendars.push(full);
|
||||
|
||||
@@ -17,7 +17,7 @@ export function createDemoCalendars(): Calendar[] {
|
||||
defaultAlertsWithoutTime: null,
|
||||
timeZone: null,
|
||||
shareWith: null,
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: false },
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: false },
|
||||
},
|
||||
{
|
||||
id: 'demo-calendar-work',
|
||||
@@ -33,7 +33,7 @@ export function createDemoCalendars(): Calendar[] {
|
||||
defaultAlertsWithoutTime: null,
|
||||
timeZone: null,
|
||||
shareWith: null,
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
|
||||
},
|
||||
{
|
||||
id: 'demo-calendar-birthdays',
|
||||
@@ -49,7 +49,7 @@ export function createDemoCalendars(): Calendar[] {
|
||||
defaultAlertsWithoutTime: null,
|
||||
timeZone: null,
|
||||
shareWith: null,
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
|
||||
/**
|
||||
@@ -190,6 +190,7 @@ export interface IJMAPClient {
|
||||
getAllAddressBooks(): Promise<AddressBook[]>;
|
||||
createAddressBook(name: string): Promise<AddressBook>;
|
||||
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
|
||||
deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise<void>;
|
||||
getContacts(addressBookId?: string): Promise<ContactCard[]>;
|
||||
getAllContacts(): Promise<ContactCard[]>;
|
||||
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
|
||||
@@ -227,6 +228,12 @@ export interface IJMAPClient {
|
||||
updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void>;
|
||||
deleteCalendarTask(taskId: string, targetAccountId?: string): Promise<void>;
|
||||
|
||||
// ── Sharing (RFC 9670 Principals) ─────────────────────────────
|
||||
supportsPrincipals(): boolean;
|
||||
getPrincipals(targetAccountId?: string): Promise<Principal[]>;
|
||||
setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise<void>;
|
||||
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
|
||||
|
||||
// ── Sieve / Filters ──────────────────────────────────────────
|
||||
getSieveAccountId(): string;
|
||||
getSieveCapabilities(): SieveCapabilities | null;
|
||||
|
||||
+101
-1
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { IJMAPClient } from "./client-interface";
|
||||
import { toWildcardQuery } from "./search-utils";
|
||||
@@ -2826,6 +2826,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
return this.hasCapability("urn:ietf:params:jmap:sieve");
|
||||
}
|
||||
|
||||
supportsPrincipals(): boolean {
|
||||
return this.hasCapability("urn:ietf:params:jmap:principals");
|
||||
}
|
||||
|
||||
getSieveAccountId(): string {
|
||||
const sieveAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:sieve"];
|
||||
return sieveAccount || this.accountId;
|
||||
@@ -3198,6 +3202,102 @@ export class JMAPClient implements IJMAPClient {
|
||||
throw new Error("Failed to update address book");
|
||||
}
|
||||
|
||||
async deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise<void> {
|
||||
const accountId = targetAccountId || this.getContactsAccountId();
|
||||
const response = await this.request([
|
||||
["AddressBook/set", { accountId, destroy: [addressBookId] }, "0"],
|
||||
], this.contactUsing());
|
||||
|
||||
const result = response.methodResponses?.[0]?.[1];
|
||||
if (result?.notDestroyed?.[addressBookId]) {
|
||||
const err = result.notDestroyed[addressBookId];
|
||||
throw new Error(err.description || "Failed to delete address book");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sharing (RFC 9670) ──────────────────────────────────────────────────────
|
||||
|
||||
private principalsUsing(): string[] {
|
||||
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:principals"];
|
||||
}
|
||||
|
||||
/**
|
||||
* List all principals visible to the user (RFC 9670). Stalwart returns the
|
||||
* full directory regardless of `filter`, so we fetch the whole list and let
|
||||
* callers filter client-side.
|
||||
*/
|
||||
async getPrincipals(targetAccountId?: string): Promise<Principal[]> {
|
||||
if (!this.supportsPrincipals()) return [];
|
||||
const accountId = targetAccountId || this.accountId;
|
||||
try {
|
||||
const response = await this.request([
|
||||
["Principal/query", { accountId }, "0"],
|
||||
["Principal/get", {
|
||||
accountId,
|
||||
"#ids": { resultOf: "0", name: "Principal/query", path: "/ids" },
|
||||
}, "1"],
|
||||
], this.principalsUsing());
|
||||
|
||||
const getResp = response.methodResponses?.find((r) => r[0] === "Principal/get");
|
||||
if (!getResp) return [];
|
||||
const list = (getResp[1].list || []) as Principal[];
|
||||
return list.map((p) => ({ ...p, accountId }));
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch principals:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add, update, or remove a principal's rights on a calendar.
|
||||
* Pass `rights: null` to revoke access.
|
||||
*/
|
||||
async setCalendarShare(
|
||||
calendarId: string,
|
||||
principalId: string,
|
||||
rights: CalendarRights | null,
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||
const response = await this.request([
|
||||
["Calendar/set", {
|
||||
accountId,
|
||||
update: { [calendarId]: { [`shareWith/${principalId}`]: rights } },
|
||||
}, "0"],
|
||||
], this.calendarUsing());
|
||||
|
||||
const result = response.methodResponses?.[0]?.[1];
|
||||
if (result?.notUpdated?.[calendarId]) {
|
||||
const err = result.notUpdated[calendarId];
|
||||
throw new Error(err.description || "Failed to update calendar share");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add, update, or remove a principal's rights on an address book.
|
||||
* Pass `rights: null` to revoke access.
|
||||
*/
|
||||
async setAddressBookShare(
|
||||
addressBookId: string,
|
||||
principalId: string,
|
||||
rights: AddressBookRights | null,
|
||||
targetAccountId?: string,
|
||||
): Promise<void> {
|
||||
const accountId = targetAccountId || this.getContactsAccountId();
|
||||
const response = await this.request([
|
||||
["AddressBook/set", {
|
||||
accountId,
|
||||
update: { [addressBookId]: { [`shareWith/${principalId}`]: rights } },
|
||||
}, "0"],
|
||||
], this.contactUsing());
|
||||
|
||||
const result = response.methodResponses?.[0]?.[1];
|
||||
if (result?.notUpdated?.[addressBookId]) {
|
||||
const err = result.notUpdated[addressBookId];
|
||||
throw new Error(err.description || "Failed to update address book share");
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchPaginatedContacts(
|
||||
accountId: string,
|
||||
filter?: Record<string, unknown>,
|
||||
|
||||
+15
-2
@@ -368,6 +368,7 @@ export interface AddressBook {
|
||||
isDefault?: boolean;
|
||||
isSubscribed?: boolean;
|
||||
myRights?: AddressBookRights;
|
||||
shareWith?: Record<string, AddressBookRights> | null;
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
isShared?: boolean;
|
||||
@@ -376,10 +377,22 @@ export interface AddressBook {
|
||||
export interface AddressBookRights {
|
||||
mayRead: boolean;
|
||||
mayWrite: boolean;
|
||||
mayShare: boolean;
|
||||
mayShare?: boolean;
|
||||
mayDelete: boolean;
|
||||
}
|
||||
|
||||
// JMAP Principals (RFC 9670)
|
||||
export interface Principal {
|
||||
id: string;
|
||||
type: 'individual' | 'group' | 'resource' | 'location' | 'other';
|
||||
name: string;
|
||||
description?: string | null;
|
||||
email?: string | null;
|
||||
timeZone?: string | null;
|
||||
capabilities?: Record<string, unknown>;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
export interface VacationResponse {
|
||||
id: string;
|
||||
isEnabled: boolean;
|
||||
@@ -442,7 +455,7 @@ export interface CalendarRights {
|
||||
mayWriteOwn: boolean;
|
||||
mayUpdatePrivate: boolean;
|
||||
mayRSVP: boolean;
|
||||
mayAdmin: boolean;
|
||||
mayShare: boolean;
|
||||
mayDelete: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ export interface PluginAPI {
|
||||
registerSettingsSection: (section: SettingsSection) => Disposable;
|
||||
registerComposerAction: (action: ComposerAction) => Disposable;
|
||||
registerSidebarWidget: (widget: SidebarWidget) => Disposable;
|
||||
registerComposerSidebar: (widget: SidebarWidget) => Disposable;
|
||||
registerDetailSidebar: (widget: SidebarWidget) => Disposable;
|
||||
registerContextMenuItem: (item: ContextMenuItem) => Disposable;
|
||||
registerNavigationRailItem: (component: React.ComponentType) => Disposable;
|
||||
@@ -609,6 +610,12 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
|
||||
return registerSlot(plugin.id, 'sidebar-widget', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
|
||||
},
|
||||
|
||||
registerComposerSidebar: (widget: SidebarWidget) => {
|
||||
requirePermission(plugin, 'ui:composer-sidebar');
|
||||
const slot = widget.side === 'right' ? 'composer-sidebar-right' : 'composer-sidebar';
|
||||
return registerSlot(plugin.id, slot, widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
|
||||
},
|
||||
|
||||
registerDetailSidebar: (widget: SidebarWidget) => {
|
||||
requirePermission(plugin, 'ui:sidebar-widget');
|
||||
return registerSlot(plugin.id, 'email-detail-sidebar', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
|
||||
|
||||
+18
-1
@@ -41,6 +41,14 @@ export interface PluginManifest {
|
||||
* so plugins can use api.i18n.t() without calling addTranslations() first.
|
||||
*/
|
||||
locales?: Record<string, Record<string, string>>;
|
||||
/**
|
||||
* External origins this plugin may embed in iframes (e.g. for YouTube,
|
||||
* Vimeo, Jitsi). Each entry is a single CSP origin like
|
||||
* "https://www.youtube-nocookie.com"
|
||||
* "https://*.example.com:8443"
|
||||
* Validated at install time and merged into the host CSP `frame-src`.
|
||||
*/
|
||||
frameOrigins?: string[];
|
||||
}
|
||||
|
||||
export interface SettingFieldSchema {
|
||||
@@ -101,6 +109,8 @@ export type SlotName =
|
||||
| 'email-banner'
|
||||
| 'email-footer'
|
||||
| 'composer-toolbar'
|
||||
| 'composer-sidebar'
|
||||
| 'composer-sidebar-right'
|
||||
| 'sidebar-widget'
|
||||
| 'email-detail-sidebar'
|
||||
| 'settings-section'
|
||||
@@ -150,6 +160,12 @@ export interface SidebarWidget {
|
||||
label: string;
|
||||
render: React.ComponentType;
|
||||
order?: number;
|
||||
/**
|
||||
* For composer sidebars, choose which side of the New Message dialog the
|
||||
* panel renders on. Defaults to `'left'` for backwards compatibility.
|
||||
* Ignored by other sidebar slots.
|
||||
*/
|
||||
side?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export interface ContextMenuItem {
|
||||
@@ -495,7 +511,8 @@ export const ALL_PERMISSIONS = [
|
||||
'auth:observe',
|
||||
'http:post',
|
||||
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
|
||||
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
|
||||
'ui:composer-toolbar', 'ui:composer-sidebar',
|
||||
'ui:sidebar-widget', 'ui:settings-section',
|
||||
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
|
||||
'ui:calendar-action', 'ui:admin-page',
|
||||
'admin:config',
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { BlockList, isIP } from 'node:net';
|
||||
|
||||
const blockedAddressRanges = new BlockList();
|
||||
blockedAddressRanges.addAddress('0.0.0.0');
|
||||
blockedAddressRanges.addAddress('127.0.0.1');
|
||||
blockedAddressRanges.addSubnet('10.0.0.0', 8);
|
||||
blockedAddressRanges.addSubnet('172.16.0.0', 12);
|
||||
blockedAddressRanges.addSubnet('192.168.0.0', 16);
|
||||
blockedAddressRanges.addSubnet('169.254.0.0', 16);
|
||||
blockedAddressRanges.addAddress('::', 'ipv6');
|
||||
blockedAddressRanges.addAddress('::1', 'ipv6');
|
||||
blockedAddressRanges.addSubnet('fc00::', 7, 'ipv6');
|
||||
blockedAddressRanges.addSubnet('fe80::', 10, 'ipv6');
|
||||
|
||||
const BLOCKED_HOSTNAMES = new Set(['localhost']);
|
||||
const BLOCKED_HOSTNAME_SUFFIXES = ['.localhost', '.local', '.internal', '.arpa', '.localdomain'];
|
||||
|
||||
function normalizeHostname(hostname: string): string {
|
||||
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
|
||||
}
|
||||
|
||||
function isBlockedIpAddress(hostname: string): boolean {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
const family = isIP(normalized);
|
||||
if (family === 4) return blockedAddressRanges.check(normalized, 'ipv4');
|
||||
if (family === 6) return blockedAddressRanges.check(normalized, 'ipv6');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true only when the URL targets a public host reachable over http(s).
|
||||
* Rejects loopback / RFC-1918 / link-local / ULA addresses, special hostname
|
||||
* suffixes (.local, .internal, .arpa, ...), URLs with embedded credentials,
|
||||
* and any hostname whose DNS resolves to a blocked address.
|
||||
*
|
||||
* Note: there is a TOCTOU window between this lookup and the eventual fetch().
|
||||
* Callers that need rebinding-safe behavior must additionally pin the resolved
|
||||
* IP at connect time (e.g. via a custom undici dispatcher).
|
||||
*/
|
||||
export async function isPublicHttpUrl(urlString: string): Promise<boolean> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(urlString);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
||||
if (url.username || url.password) return false;
|
||||
|
||||
const hostname = normalizeHostname(url.hostname);
|
||||
if (!hostname) return false;
|
||||
if (BLOCKED_HOSTNAMES.has(hostname)) return false;
|
||||
if (BLOCKED_HOSTNAME_SUFFIXES.some((suffix) => hostname.endsWith(suffix))) return false;
|
||||
|
||||
if (isBlockedIpAddress(hostname)) return false;
|
||||
if (isIP(hostname)) return true;
|
||||
|
||||
try {
|
||||
const records = await lookup(hostname, { all: true, verbatim: true });
|
||||
if (records.length === 0) return false;
|
||||
return records.every((record) => !isBlockedIpAddress(record.address));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+72
-11
@@ -51,6 +51,53 @@ function unfoldLines(vcf: string): string {
|
||||
return vcf.replace(/\r\n[ \t]/g, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
}
|
||||
|
||||
// vCard 2.1 quoted-printable soft line breaks: a line ending in `=` continues
|
||||
// onto the next line. This is distinct from RFC 5545/6350 line folding (which
|
||||
// uses leading whitespace and is already handled in unfoldLines). Only merge
|
||||
// when the originating line declares ENCODING=QUOTED-PRINTABLE so we don't
|
||||
// accidentally splice unrelated lines.
|
||||
function joinQpSoftBreaks(lines: string[]): string[] {
|
||||
const result: string[] = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
let line = lines[i];
|
||||
if (/;ENCODING=QUOTED-PRINTABLE/i.test(line)) {
|
||||
while (line.endsWith("=") && i + 1 < lines.length) {
|
||||
i++;
|
||||
line = line.slice(0, -1) + lines[i];
|
||||
}
|
||||
}
|
||||
result.push(line);
|
||||
i++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function decodeQuotedPrintable(input: string, charset?: string): string {
|
||||
const cleaned = input.replace(/=\r?\n/g, "");
|
||||
const bytes: number[] = [];
|
||||
let i = 0;
|
||||
while (i < cleaned.length) {
|
||||
const ch = cleaned[i];
|
||||
if (ch === "=" && i + 2 < cleaned.length) {
|
||||
const hex = cleaned.substring(i + 1, i + 3);
|
||||
if (/^[0-9A-Fa-f]{2}$/.test(hex)) {
|
||||
bytes.push(parseInt(hex, 16));
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
bytes.push(cleaned.charCodeAt(i) & 0xff);
|
||||
i += 1;
|
||||
}
|
||||
const label = (charset || "utf-8").toLowerCase();
|
||||
try {
|
||||
return new TextDecoder(label).decode(new Uint8Array(bytes));
|
||||
} catch {
|
||||
return new TextDecoder("utf-8").decode(new Uint8Array(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
function decodeValue(raw: string): string {
|
||||
return raw
|
||||
.replace(/\\n/gi, "\n")
|
||||
@@ -77,7 +124,9 @@ function parseParams(paramStr: string): Record<string, string> {
|
||||
params[part.substring(0, eq).toUpperCase()] = part.substring(eq + 1).replace(/"/g, "");
|
||||
} else {
|
||||
const upper = part.toUpperCase();
|
||||
if (["WORK", "HOME", "CELL", "FAX", "VOICE", "PREF", "PAGER", "VIDEO", "TEXT", "TEXTPHONE"].includes(upper)) {
|
||||
if (upper === "QUOTED-PRINTABLE" || upper === "BASE64") {
|
||||
params.ENCODING = upper;
|
||||
} else if (["WORK", "HOME", "CELL", "FAX", "VOICE", "PREF", "PAGER", "VIDEO", "TEXT", "TEXTPHONE"].includes(upper)) {
|
||||
params.TYPE = params.TYPE ? `${params.TYPE},${upper}` : upper;
|
||||
}
|
||||
}
|
||||
@@ -118,7 +167,7 @@ function contextToType(contexts: Record<string, boolean> | undefined): string {
|
||||
|
||||
export function parseVCard(vcfString: string): ContactCard[] {
|
||||
const text = unfoldLines(vcfString);
|
||||
const lines = text.split("\n");
|
||||
const lines = joinQpSoftBreaks(text.split("\n"));
|
||||
const contacts: ContactCard[] = [];
|
||||
let current: Record<string, string[]> | null = null;
|
||||
|
||||
@@ -163,8 +212,13 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
||||
const paramStr = semiIdx > 0 ? fullKey.substring(semiIdx + 1) : "";
|
||||
const params = parseParams(paramStr);
|
||||
|
||||
const isQuotedPrintable = params.ENCODING?.toUpperCase() === "QUOTED-PRINTABLE";
|
||||
|
||||
for (const rawValue of values) {
|
||||
const val = decodeValue(rawValue);
|
||||
const decoded = isQuotedPrintable
|
||||
? decodeQuotedPrintable(rawValue, params.CHARSET)
|
||||
: rawValue;
|
||||
const val = decodeValue(decoded);
|
||||
|
||||
switch (propName) {
|
||||
case "FN":
|
||||
@@ -182,13 +236,17 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
||||
break;
|
||||
|
||||
case "N": {
|
||||
// vCard N: family;given;additional;prefix;suffix (RFC 6350 §6.2.2)
|
||||
// Mapped to JSContact-standard kinds (RFC 9553 §2.2.1):
|
||||
// prefix→title, additional→given2, suffix→generation.
|
||||
// Pushed in natural display order so `isOrdered: true` renders correctly.
|
||||
const nParts = val.split(";");
|
||||
const components: NameComponent[] = [];
|
||||
if (nParts[3]) components.push({ kind: "prefix", value: nParts[3] });
|
||||
if (nParts[3]) components.push({ kind: "title", value: nParts[3] });
|
||||
if (nParts[1]) components.push({ kind: "given", value: nParts[1] });
|
||||
if (nParts[2]) components.push({ kind: "additional", value: nParts[2] });
|
||||
if (nParts[2]) components.push({ kind: "given2", value: nParts[2] });
|
||||
if (nParts[0]) components.push({ kind: "surname", value: nParts[0] });
|
||||
if (nParts[4]) components.push({ kind: "suffix", value: nParts[4] });
|
||||
if (nParts[4]) components.push({ kind: "generation", value: nParts[4] });
|
||||
if (components.length > 0) {
|
||||
card.name = { components, isOrdered: true };
|
||||
}
|
||||
@@ -558,11 +616,14 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
}
|
||||
|
||||
const components = contact.name?.components || [];
|
||||
const given = components.find(c => c.kind === "given")?.value || "";
|
||||
const surname = components.find(c => c.kind === "surname")?.value || "";
|
||||
const prefix = components.find(c => c.kind === "prefix")?.value || "";
|
||||
const suffix = components.find(c => c.kind === "suffix")?.value || "";
|
||||
const additional = components.find(c => c.kind === "additional")?.value || "";
|
||||
const findKind = (...kinds: string[]) =>
|
||||
components.find(c => kinds.includes(c.kind))?.value || "";
|
||||
const given = findKind("given");
|
||||
const surname = findKind("surname");
|
||||
// Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases.
|
||||
const prefix = findKind("title", "prefix");
|
||||
const suffix = findKind("generation", "suffix");
|
||||
const additional = findKind("given2", "additional", "middle");
|
||||
|
||||
const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ") || contact.name?.full || "";
|
||||
if (fn) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+33
-2
@@ -1815,7 +1815,13 @@
|
||||
"renamed": "Adressbuch umbenannt",
|
||||
"rename_failed": "Adressbuch konnte nicht umbenannt werden",
|
||||
"default": "Standard",
|
||||
"manage": "Adressbücher verwalten"
|
||||
"manage": "Adressbücher verwalten",
|
||||
"share": "Adressbuch freigeben",
|
||||
"new_contact_in_book": "Neuer Kontakt in diesem Adressbuch",
|
||||
"delete": "Adressbuch löschen",
|
||||
"confirm_delete": "„{name}\" löschen? Alle Kontakte in diesem Adressbuch werden entfernt.",
|
||||
"deleted": "Adressbuch gelöscht",
|
||||
"delete_failed": "Adressbuch konnte nicht gelöscht werden"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "E-Mail-Adressen",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"confirm_clear": "Alle Ereignisse aus \"{name}\" löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"clear_events": "Ereignisse löschen",
|
||||
"events_cleared": "{count} Ereignisse gelöscht",
|
||||
"error_clear": "Kalenderereignisse konnten nicht gelöscht werden"
|
||||
"error_clear": "Kalenderereignisse konnten nicht gelöscht werden",
|
||||
"share": "Kalender freigeben",
|
||||
"new_event_in_calendar": "Neuer Termin in diesem Kalender"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "iCal-Abonnement",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Die Suche ist in der vereinheitlichten Ansicht nicht verfügbar"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "„{name}\" freigeben",
|
||||
"description": "Anderen Benutzern oder Gruppen auf diesem Server Zugriff gewähren. Änderungen werden sofort wirksam.",
|
||||
"no_shares": "Noch nicht freigegeben.",
|
||||
"add_person": "Person oder Gruppe hinzufügen",
|
||||
"search_placeholder": "Nach Name oder E-Mail suchen…",
|
||||
"loading_principals": "Benutzer werden geladen…",
|
||||
"no_principals": "Keine weiteren Benutzer oder Gruppen gefunden.",
|
||||
"no_match": "Keine Treffer.",
|
||||
"remove": "Zugriff entfernen",
|
||||
"group": "Gruppe",
|
||||
"share_added": "Zugriff erteilt",
|
||||
"share_updated": "Zugriff aktualisiert",
|
||||
"share_removed": "Zugriff entfernt",
|
||||
"share_failed": "Freigabe konnte nicht aktualisiert werden",
|
||||
"preset": {
|
||||
"freeBusy": "Nur Frei/Belegt",
|
||||
"read": "Nur lesen",
|
||||
"readWrite": "Lesen & schreiben",
|
||||
"manager": "Verwalten",
|
||||
"custom": "Benutzerdefiniert"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-2
@@ -454,6 +454,9 @@
|
||||
"discard_draft_title": "Discard draft?",
|
||||
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
|
||||
"saving": "Saving...",
|
||||
"sending": "Sending...",
|
||||
"add_link": "Add link",
|
||||
"link_url_prompt": "Enter the URL",
|
||||
"draft_saved": "Draft saved",
|
||||
"save_failed": "Failed to save",
|
||||
"to_placeholder": "Recipient email addresses",
|
||||
@@ -1819,7 +1822,13 @@
|
||||
"renamed": "Address book renamed",
|
||||
"rename_failed": "Failed to rename address book",
|
||||
"default": "Default",
|
||||
"manage": "Manage address books"
|
||||
"manage": "Manage address books",
|
||||
"share": "Share address book",
|
||||
"new_contact_in_book": "New contact in this address book",
|
||||
"delete": "Delete address book",
|
||||
"confirm_delete": "Delete \"{name}\"? All contacts in this address book will be removed.",
|
||||
"deleted": "Address book deleted",
|
||||
"delete_failed": "Failed to delete address book"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Email Addresses",
|
||||
@@ -2344,7 +2353,9 @@
|
||||
"error_delete": "Failed to delete calendar",
|
||||
"caldav_url": "CalDAV URL",
|
||||
"copy_url": "Copy CalDAV URL",
|
||||
"url_copied": "CalDAV URL copied to clipboard"
|
||||
"url_copied": "CalDAV URL copied to clipboard",
|
||||
"share": "Share calendar",
|
||||
"new_event_in_calendar": "New event in this calendar"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "iCal Subscription",
|
||||
@@ -2426,6 +2437,29 @@
|
||||
"overdue": "Overdue"
|
||||
}
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Share \"{name}\"",
|
||||
"description": "Grant access to other users or groups on this server. Changes take effect immediately.",
|
||||
"no_shares": "Not shared with anyone yet.",
|
||||
"add_person": "Add person or group",
|
||||
"search_placeholder": "Search by name or email…",
|
||||
"loading_principals": "Loading users…",
|
||||
"no_principals": "No other users or groups found.",
|
||||
"no_match": "No matches.",
|
||||
"remove": "Remove access",
|
||||
"group": "Group",
|
||||
"share_added": "Access granted",
|
||||
"share_updated": "Access updated",
|
||||
"share_removed": "Access removed",
|
||||
"share_failed": "Failed to update sharing",
|
||||
"preset": {
|
||||
"freeBusy": "Free/busy only",
|
||||
"read": "Read only",
|
||||
"readWrite": "Read & write",
|
||||
"manager": "Manager",
|
||||
"custom": "Custom"
|
||||
}
|
||||
},
|
||||
"advanced_search": {
|
||||
"title": "Advanced Search",
|
||||
"from": "From",
|
||||
|
||||
+33
-2
@@ -1815,7 +1815,13 @@
|
||||
"renamed": "Libreta de direcciones renombrada",
|
||||
"rename_failed": "Error al renombrar la libreta de direcciones",
|
||||
"default": "Predeterminada",
|
||||
"manage": "Administrar libretas de direcciones"
|
||||
"manage": "Administrar libretas de direcciones",
|
||||
"share": "Compartir libreta de direcciones",
|
||||
"new_contact_in_book": "Nuevo contacto en esta libreta",
|
||||
"delete": "Eliminar libreta de direcciones",
|
||||
"confirm_delete": "¿Eliminar «{name}»? Todos los contactos de esta libreta se eliminarán.",
|
||||
"deleted": "Libreta de direcciones eliminada",
|
||||
"delete_failed": "No se pudo eliminar la libreta de direcciones"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Direcciones de correo",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"confirm_clear": "¿Borrar todos los eventos de \"{name}\"? Esta acción no se puede deshacer.",
|
||||
"clear_events": "Borrar eventos",
|
||||
"events_cleared": "{count} eventos borrados",
|
||||
"error_clear": "No se pudieron borrar los eventos del calendario"
|
||||
"error_clear": "No se pudieron borrar los eventos del calendario",
|
||||
"share": "Compartir calendario",
|
||||
"new_event_in_calendar": "Nuevo evento en este calendario"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Suscripción iCal",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "La búsqueda no está disponible en la vista unificada"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Compartir «{name}»",
|
||||
"description": "Concede acceso a otros usuarios o grupos de este servidor. Los cambios surten efecto inmediatamente.",
|
||||
"no_shares": "Aún no se ha compartido con nadie.",
|
||||
"add_person": "Añadir persona o grupo",
|
||||
"search_placeholder": "Buscar por nombre o correo…",
|
||||
"loading_principals": "Cargando usuarios…",
|
||||
"no_principals": "No se han encontrado otros usuarios ni grupos.",
|
||||
"no_match": "Sin resultados.",
|
||||
"remove": "Quitar acceso",
|
||||
"group": "Grupo",
|
||||
"share_added": "Acceso concedido",
|
||||
"share_updated": "Acceso actualizado",
|
||||
"share_removed": "Acceso retirado",
|
||||
"share_failed": "No se pudo actualizar el uso compartido",
|
||||
"preset": {
|
||||
"freeBusy": "Solo disponibilidad",
|
||||
"read": "Solo lectura",
|
||||
"readWrite": "Lectura y escritura",
|
||||
"manager": "Administrador",
|
||||
"custom": "Personalizado"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-2
@@ -1815,7 +1815,13 @@
|
||||
"renamed": "Carnet d'adresses renommé",
|
||||
"rename_failed": "Échec du renommage du carnet d'adresses",
|
||||
"default": "Par défaut",
|
||||
"manage": "Gérer les carnets d'adresses"
|
||||
"manage": "Gérer les carnets d'adresses",
|
||||
"share": "Partager le carnet d'adresses",
|
||||
"new_contact_in_book": "Nouveau contact dans ce carnet d'adresses",
|
||||
"delete": "Supprimer le carnet d'adresses",
|
||||
"confirm_delete": "Supprimer « {name} » ? Tous les contacts de ce carnet d'adresses seront supprimés.",
|
||||
"deleted": "Carnet d'adresses supprimé",
|
||||
"delete_failed": "Échec de la suppression du carnet d'adresses"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Adresses e-mail",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"confirm_clear": "Supprimer tous les événements de \"{name}\" ? Cette action est irréversible.",
|
||||
"clear_events": "Supprimer les événements",
|
||||
"events_cleared": "{count} événements supprimés",
|
||||
"error_clear": "Impossible de supprimer les événements du calendrier"
|
||||
"error_clear": "Impossible de supprimer les événements du calendrier",
|
||||
"share": "Partager le calendrier",
|
||||
"new_event_in_calendar": "Nouvel événement dans ce calendrier"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Abonnement iCal",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "La recherche n'est pas disponible dans la vue unifiée"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Partager « {name} »",
|
||||
"description": "Accordez l'accès à d'autres utilisateurs ou groupes de ce serveur. Les modifications sont immédiates.",
|
||||
"no_shares": "Pas encore partagé.",
|
||||
"add_person": "Ajouter une personne ou un groupe",
|
||||
"search_placeholder": "Rechercher par nom ou e-mail…",
|
||||
"loading_principals": "Chargement des utilisateurs…",
|
||||
"no_principals": "Aucun autre utilisateur ou groupe trouvé.",
|
||||
"no_match": "Aucun résultat.",
|
||||
"remove": "Révoquer l'accès",
|
||||
"group": "Groupe",
|
||||
"share_added": "Accès accordé",
|
||||
"share_updated": "Accès mis à jour",
|
||||
"share_removed": "Accès révoqué",
|
||||
"share_failed": "Échec de la mise à jour du partage",
|
||||
"preset": {
|
||||
"freeBusy": "Disponibilité uniquement",
|
||||
"read": "Lecture seule",
|
||||
"readWrite": "Lecture & écriture",
|
||||
"manager": "Gestionnaire",
|
||||
"custom": "Personnalisé"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-2
@@ -1815,7 +1815,13 @@
|
||||
"renamed": "Rubrica rinominata",
|
||||
"rename_failed": "Impossibile rinominare la rubrica",
|
||||
"default": "Predefinita",
|
||||
"manage": "Gestisci rubriche"
|
||||
"manage": "Gestisci rubriche",
|
||||
"share": "Condividi rubrica",
|
||||
"new_contact_in_book": "Nuovo contatto in questa rubrica",
|
||||
"delete": "Elimina rubrica",
|
||||
"confirm_delete": "Eliminare \"{name}\"? Tutti i contatti in questa rubrica verranno rimossi.",
|
||||
"deleted": "Rubrica eliminata",
|
||||
"delete_failed": "Impossibile eliminare la rubrica"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Indirizzi email",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"confirm_clear": "Cancellare tutti gli eventi da \"{name}\"? Questa azione non può essere annullata.",
|
||||
"clear_events": "Cancella eventi",
|
||||
"events_cleared": "{count} eventi cancellati",
|
||||
"error_clear": "Impossibile cancellare gli eventi del calendario"
|
||||
"error_clear": "Impossibile cancellare gli eventi del calendario",
|
||||
"share": "Condividi calendario",
|
||||
"new_event_in_calendar": "Nuovo evento in questo calendario"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Abbonamento iCal",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "La ricerca non è disponibile nella vista unificata"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Condividi \"{name}\"",
|
||||
"description": "Concedi l'accesso ad altri utenti o gruppi su questo server. Le modifiche hanno effetto immediato.",
|
||||
"no_shares": "Non ancora condiviso.",
|
||||
"add_person": "Aggiungi persona o gruppo",
|
||||
"search_placeholder": "Cerca per nome o email…",
|
||||
"loading_principals": "Caricamento utenti…",
|
||||
"no_principals": "Nessun altro utente o gruppo trovato.",
|
||||
"no_match": "Nessun risultato.",
|
||||
"remove": "Rimuovi accesso",
|
||||
"group": "Gruppo",
|
||||
"share_added": "Accesso concesso",
|
||||
"share_updated": "Accesso aggiornato",
|
||||
"share_removed": "Accesso rimosso",
|
||||
"share_failed": "Impossibile aggiornare la condivisione",
|
||||
"preset": {
|
||||
"freeBusy": "Solo libero/occupato",
|
||||
"read": "Sola lettura",
|
||||
"readWrite": "Lettura e scrittura",
|
||||
"manager": "Gestore",
|
||||
"custom": "Personalizzato"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-2
@@ -1815,7 +1815,13 @@
|
||||
"renamed": "アドレス帳の名前を変更しました",
|
||||
"rename_failed": "アドレス帳の名前変更に失敗しました",
|
||||
"default": "デフォルト",
|
||||
"manage": "アドレス帳を管理"
|
||||
"manage": "アドレス帳を管理",
|
||||
"share": "アドレス帳を共有",
|
||||
"new_contact_in_book": "このアドレス帳に新規連絡先",
|
||||
"delete": "アドレス帳を削除",
|
||||
"confirm_delete": "「{name}」を削除しますか?このアドレス帳のすべての連絡先が削除されます。",
|
||||
"deleted": "アドレス帳を削除しました",
|
||||
"delete_failed": "アドレス帳の削除に失敗しました"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "メールアドレス",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"confirm_clear": "\"{name}\"のすべてのイベントを削除しますか?この操作は元に戻せません。",
|
||||
"clear_events": "イベントを削除",
|
||||
"events_cleared": "{count}件のイベントを削除しました",
|
||||
"error_clear": "カレンダーイベントの削除に失敗しました"
|
||||
"error_clear": "カレンダーイベントの削除に失敗しました",
|
||||
"share": "カレンダーを共有",
|
||||
"new_event_in_calendar": "このカレンダーに新規イベント"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "iCal購読",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "統合ビューでは検索を利用できません"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "「{name}」を共有",
|
||||
"description": "このサーバー上の他のユーザーまたはグループにアクセス権を付与します。変更はすぐに反映されます。",
|
||||
"no_shares": "まだ誰にも共有されていません。",
|
||||
"add_person": "ユーザーまたはグループを追加",
|
||||
"search_placeholder": "名前またはメールで検索…",
|
||||
"loading_principals": "ユーザーを読み込み中…",
|
||||
"no_principals": "他のユーザーまたはグループは見つかりません。",
|
||||
"no_match": "一致する項目がありません。",
|
||||
"remove": "アクセス権を削除",
|
||||
"group": "グループ",
|
||||
"share_added": "アクセス権を付与しました",
|
||||
"share_updated": "アクセス権を更新しました",
|
||||
"share_removed": "アクセス権を削除しました",
|
||||
"share_failed": "共有の更新に失敗しました",
|
||||
"preset": {
|
||||
"freeBusy": "空き時間情報のみ",
|
||||
"read": "読み取り専用",
|
||||
"readWrite": "読み取り・書き込み",
|
||||
"manager": "管理者",
|
||||
"custom": "カスタム"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-2
@@ -1815,7 +1815,13 @@
|
||||
"renamed": "주소록 이름이 변경되었습니다",
|
||||
"rename_failed": "주소록 이름 변경 실패",
|
||||
"default": "기본",
|
||||
"manage": "주소록 관리"
|
||||
"manage": "주소록 관리",
|
||||
"share": "주소록 공유",
|
||||
"new_contact_in_book": "이 주소록에 새 연락처",
|
||||
"delete": "주소록 삭제",
|
||||
"confirm_delete": "\"{name}\"을(를) 삭제하시겠습니까? 이 주소록의 모든 연락처가 삭제됩니다.",
|
||||
"deleted": "주소록이 삭제되었습니다",
|
||||
"delete_failed": "주소록 삭제에 실패했습니다"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "이메일",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"error_delete": "캘린더를 삭제하지 못했어요",
|
||||
"caldav_url": "CalDAV URL",
|
||||
"copy_url": "CalDAV URL 복사",
|
||||
"url_copied": "CalDAV URL이 클립보드에 복사되었어요"
|
||||
"url_copied": "CalDAV URL이 클립보드에 복사되었어요",
|
||||
"share": "캘린더 공유",
|
||||
"new_event_in_calendar": "이 캘린더에 새 일정"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "iCal 구독",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "통합 보기에서는 검색을 사용할 수 없습니다"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "\"{name}\" 공유",
|
||||
"description": "이 서버의 다른 사용자나 그룹에 액세스 권한을 부여합니다. 변경 사항은 즉시 적용됩니다.",
|
||||
"no_shares": "아직 공유되지 않았습니다.",
|
||||
"add_person": "사용자 또는 그룹 추가",
|
||||
"search_placeholder": "이름 또는 이메일로 검색…",
|
||||
"loading_principals": "사용자 불러오는 중…",
|
||||
"no_principals": "다른 사용자나 그룹을 찾을 수 없습니다.",
|
||||
"no_match": "일치하는 항목이 없습니다.",
|
||||
"remove": "액세스 권한 제거",
|
||||
"group": "그룹",
|
||||
"share_added": "액세스 권한이 부여되었습니다",
|
||||
"share_updated": "액세스 권한이 업데이트되었습니다",
|
||||
"share_removed": "액세스 권한이 제거되었습니다",
|
||||
"share_failed": "공유 업데이트에 실패했습니다",
|
||||
"preset": {
|
||||
"freeBusy": "한가함/바쁨만",
|
||||
"read": "읽기 전용",
|
||||
"readWrite": "읽기 및 쓰기",
|
||||
"manager": "관리자",
|
||||
"custom": "사용자 지정"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-2
@@ -1811,7 +1811,13 @@
|
||||
"renamed": "Adrešu grāmata pārdēvēta",
|
||||
"rename_failed": "Neizdevās pārdēvēt adrešu grāmatu",
|
||||
"default": "Noklusējuma",
|
||||
"manage": "Pārvaldīt adrešu grāmatas"
|
||||
"manage": "Pārvaldīt adrešu grāmatas",
|
||||
"share": "Kopīgot adrešu grāmatu",
|
||||
"new_contact_in_book": "Jauns kontakts šajā adrešu grāmatā",
|
||||
"delete": "Dzēst adrešu grāmatu",
|
||||
"confirm_delete": "Dzēst \"{name}\"? Visi kontakti šajā adrešu grāmatā tiks noņemti.",
|
||||
"deleted": "Adrešu grāmata dzēsta",
|
||||
"delete_failed": "Neizdevās dzēst adrešu grāmatu"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "E-pasta adreses",
|
||||
@@ -2339,7 +2345,9 @@
|
||||
"error_delete": "Neizdevās izdzēst kalendāru",
|
||||
"caldav_url": "CalDAV URL",
|
||||
"copy_url": "Kopēt CalDAV URL",
|
||||
"url_copied": "CalDAV URL nokopēts starpliktuvē"
|
||||
"url_copied": "CalDAV URL nokopēts starpliktuvē",
|
||||
"share": "Kopīgot kalendāru",
|
||||
"new_event_in_calendar": "Jauns notikums šajā kalendārā"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "iCal abonements",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Meklēšana nav pieejama apvienotajā skatā"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Kopīgot \"{name}\"",
|
||||
"description": "Piešķiriet piekļuvi citiem lietotājiem vai grupām šajā serverī. Izmaiņas stājas spēkā nekavējoties.",
|
||||
"no_shares": "Vēl nav kopīgots.",
|
||||
"add_person": "Pievienot personu vai grupu",
|
||||
"search_placeholder": "Meklēt pēc vārda vai e-pasta…",
|
||||
"loading_principals": "Ielādē lietotājus…",
|
||||
"no_principals": "Citi lietotāji vai grupas nav atrastas.",
|
||||
"no_match": "Nav atbilstību.",
|
||||
"remove": "Noņemt piekļuvi",
|
||||
"group": "Grupa",
|
||||
"share_added": "Piekļuve piešķirta",
|
||||
"share_updated": "Piekļuve atjaunināta",
|
||||
"share_removed": "Piekļuve noņemta",
|
||||
"share_failed": "Neizdevās atjaunināt kopīgošanu",
|
||||
"preset": {
|
||||
"freeBusy": "Tikai brīvs/aizņemts",
|
||||
"read": "Tikai lasīšana",
|
||||
"readWrite": "Lasīšana un rakstīšana",
|
||||
"manager": "Pārvaldnieks",
|
||||
"custom": "Pielāgots"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-2
@@ -1815,7 +1815,13 @@
|
||||
"renamed": "Adresboek hernoemd",
|
||||
"rename_failed": "Adresboek hernoemen mislukt",
|
||||
"default": "Standaard",
|
||||
"manage": "Adresboeken beheren"
|
||||
"manage": "Adresboeken beheren",
|
||||
"share": "Adresboek delen",
|
||||
"new_contact_in_book": "Nieuw contact in dit adresboek",
|
||||
"delete": "Adresboek verwijderen",
|
||||
"confirm_delete": "\"{name}\" verwijderen? Alle contacten in dit adresboek worden verwijderd.",
|
||||
"deleted": "Adresboek verwijderd",
|
||||
"delete_failed": "Adresboek kon niet worden verwijderd"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "E-mailadressen",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"confirm_clear": "Alle afspraken uit \"{name}\" verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||
"clear_events": "Afspraken verwijderen",
|
||||
"events_cleared": "{count} afspraken verwijderd",
|
||||
"error_clear": "Kan agendagebeurtenissen niet verwijderen"
|
||||
"error_clear": "Kan agendagebeurtenissen niet verwijderen",
|
||||
"share": "Agenda delen",
|
||||
"new_event_in_calendar": "Nieuwe afspraak in deze agenda"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "iCal-abonnement",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Zoeken is niet beschikbaar in de gecombineerde weergave"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "\"{name}\" delen",
|
||||
"description": "Geef andere gebruikers of groepen op deze server toegang. Wijzigingen zijn direct van kracht.",
|
||||
"no_shares": "Nog niet gedeeld.",
|
||||
"add_person": "Persoon of groep toevoegen",
|
||||
"search_placeholder": "Zoeken op naam of e-mail…",
|
||||
"loading_principals": "Gebruikers laden…",
|
||||
"no_principals": "Geen andere gebruikers of groepen gevonden.",
|
||||
"no_match": "Geen overeenkomsten.",
|
||||
"remove": "Toegang intrekken",
|
||||
"group": "Groep",
|
||||
"share_added": "Toegang verleend",
|
||||
"share_updated": "Toegang bijgewerkt",
|
||||
"share_removed": "Toegang ingetrokken",
|
||||
"share_failed": "Delen kon niet worden bijgewerkt",
|
||||
"preset": {
|
||||
"freeBusy": "Alleen vrij/bezet",
|
||||
"read": "Alleen lezen",
|
||||
"readWrite": "Lezen en schrijven",
|
||||
"manager": "Beheerder",
|
||||
"custom": "Aangepast"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-2
@@ -1815,7 +1815,13 @@
|
||||
"renamed": "Zmieniono nazwę książki adresowej",
|
||||
"rename_failed": "Nie udało się zmienić nazwy książki adresowej",
|
||||
"default": "Domyślna",
|
||||
"manage": "Zarządzaj książkami adresowymi"
|
||||
"manage": "Zarządzaj książkami adresowymi",
|
||||
"share": "Udostępnij książkę adresową",
|
||||
"new_contact_in_book": "Nowy kontakt w tej książce adresowej",
|
||||
"delete": "Usuń książkę adresową",
|
||||
"confirm_delete": "Usunąć „{name}\"? Wszystkie kontakty w tej książce adresowej zostaną usunięte.",
|
||||
"deleted": "Książka adresowa usunięta",
|
||||
"delete_failed": "Nie udało się usunąć książki adresowej"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Adresy e-mail",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"error_delete": "Nie udało się usunąć kalendarza",
|
||||
"caldav_url": "Adres URL CalDAV",
|
||||
"copy_url": "Kopiuj adres URL CalDAV",
|
||||
"url_copied": "Adres URL CalDAV skopiowano do schowka"
|
||||
"url_copied": "Adres URL CalDAV skopiowano do schowka",
|
||||
"share": "Udostępnij kalendarz",
|
||||
"new_event_in_calendar": "Nowe wydarzenie w tym kalendarzu"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Subskrypcja iCal",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Wyszukiwanie jest niedostępne w widoku ujednoliconym"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Udostępnij „{name}\"",
|
||||
"description": "Udziel dostępu innym użytkownikom lub grupom na tym serwerze. Zmiany są natychmiastowe.",
|
||||
"no_shares": "Jeszcze nie udostępniono.",
|
||||
"add_person": "Dodaj osobę lub grupę",
|
||||
"search_placeholder": "Szukaj po imieniu lub e-mailu…",
|
||||
"loading_principals": "Ładowanie użytkowników…",
|
||||
"no_principals": "Nie znaleziono innych użytkowników ani grup.",
|
||||
"no_match": "Brak wyników.",
|
||||
"remove": "Usuń dostęp",
|
||||
"group": "Grupa",
|
||||
"share_added": "Dostęp przyznany",
|
||||
"share_updated": "Dostęp zaktualizowany",
|
||||
"share_removed": "Dostęp usunięty",
|
||||
"share_failed": "Nie udało się zaktualizować udostępniania",
|
||||
"preset": {
|
||||
"freeBusy": "Tylko dostępność",
|
||||
"read": "Tylko do odczytu",
|
||||
"readWrite": "Odczyt i zapis",
|
||||
"manager": "Menedżer",
|
||||
"custom": "Niestandardowe"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+68
-37
@@ -20,7 +20,7 @@
|
||||
"generic": "Ocorreu um erro. Por favor, tente novamente.",
|
||||
"totp_required": "É necessário um código de autenticação de dois fatores. Insira seu código abaixo.",
|
||||
"totp_invalid": "Código de autenticação inválido. Verifique seu aplicativo de autenticação.",
|
||||
"oauth_discovery_failed": "SSO está ativado mas o provedor de identidade não pôde ser contactado. Verifique sua configuração OAuth."
|
||||
"oauth_discovery_failed": "SSO está ativado mas o provedor de identidade não pôde ser contatado. Verifique sua configuração OAuth."
|
||||
},
|
||||
"show_password": "Mostrar senha",
|
||||
"hide_password": "Ocultar senha",
|
||||
@@ -80,7 +80,7 @@
|
||||
"calendar": "Calendário",
|
||||
"settings": "Configurações",
|
||||
"admin": "Admin",
|
||||
"files": "Ficheiros",
|
||||
"files": "Arquivos",
|
||||
"loading_mailboxes": "Carregando caixas de entrada...",
|
||||
"push_connected": "Atualizações em tempo real ativas",
|
||||
"push_disconnected": "Atualizações em tempo real inativas",
|
||||
@@ -505,7 +505,7 @@
|
||||
"show_less": "Mostrar menos",
|
||||
"forgot_attachment": {
|
||||
"title": "Esqueceu um anexo?",
|
||||
"message": "A sua mensagem menciona \"{keyword}\" mas nenhum ficheiro está anexado. Enviar mesmo assim?",
|
||||
"message": "A sua mensagem menciona \"{keyword}\" mas nenhum arquivo está anexado. Enviar mesmo assim?",
|
||||
"send_anyway": "Enviar mesmo assim",
|
||||
"back": "Voltar à edição"
|
||||
}
|
||||
@@ -669,7 +669,7 @@
|
||||
"security": "Segurança",
|
||||
"encryption": "Criptografia",
|
||||
"files": "Arquivos",
|
||||
"contacts": "Contactos",
|
||||
"contacts": "Contatos",
|
||||
"sidebar_apps": "Apps da barra lateral",
|
||||
"notifications": "Notificações",
|
||||
"layout": "Layout",
|
||||
@@ -1322,12 +1322,12 @@
|
||||
"manage_description": "Adicionar, editar ou remover apps personalizados da barra lateral"
|
||||
},
|
||||
"contacts": {
|
||||
"title": "Contactos",
|
||||
"description": "Importar e exportar os seus contactos",
|
||||
"import_label": "Importar contactos",
|
||||
"import_description": "Importar contactos de um ficheiro vCard (.vcf)",
|
||||
"export_label": "Exportar contactos",
|
||||
"export_description": "Exportar todos os contactos como ficheiro vCard (.vcf)",
|
||||
"title": "Contatos",
|
||||
"description": "Importar e exportar os seus contatos",
|
||||
"import_label": "Importar contatos",
|
||||
"import_description": "Importar contatos de um arquivo vCard (.vcf)",
|
||||
"export_label": "Exportar contatos",
|
||||
"export_description": "Exportar todos os contatos como arquivo vCard (.vcf)",
|
||||
"manage_title": "Catálogos de endereços",
|
||||
"manage_description": "Renomeie seus catálogos de endereços",
|
||||
"no_address_books": "Nenhum catálogo de endereços encontrado",
|
||||
@@ -1503,7 +1503,7 @@
|
||||
},
|
||||
"folder_layout": {
|
||||
"label": "Navegação de pastas",
|
||||
"description": "Escolha como as pastas são apresentadas: integradas com os ficheiros ou numa árvore na barra lateral",
|
||||
"description": "Escolha como as pastas são apresentadas: integradas com os arquivos ou numa árvore na barra lateral",
|
||||
"inline": "Integrado",
|
||||
"sidebar": "Barra lateral"
|
||||
},
|
||||
@@ -1815,7 +1815,13 @@
|
||||
"renamed": "Catálogo de endereços renomeado",
|
||||
"rename_failed": "Falha ao renomear o catálogo de endereços",
|
||||
"default": "Padrão",
|
||||
"manage": "Gerenciar catálogos de endereços"
|
||||
"manage": "Gerenciar catálogos de endereços",
|
||||
"share": "Compartilhar lista de contatos",
|
||||
"new_contact_in_book": "Novo contato nesta lista",
|
||||
"delete": "Excluir lista de contatos",
|
||||
"confirm_delete": "Excluir \"{name}\"? Todos os contatos desta lista serão removidos.",
|
||||
"deleted": "Lista de contatos excluída",
|
||||
"delete_failed": "Falha ao excluir a lista de contatos"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Endereços de e-mail",
|
||||
@@ -1868,7 +1874,7 @@
|
||||
"cert_already_imported": "Certificado já importado",
|
||||
"cert_imported": "Certificado importado",
|
||||
"cert_import_failed": "Falha ao importar o certificado",
|
||||
"section_contact": "Contact details",
|
||||
"section_contact": "Detalhes do contato",
|
||||
"section_work": "Work",
|
||||
"section_personal": "Personal",
|
||||
"email_default_label": "Email",
|
||||
@@ -2237,7 +2243,7 @@
|
||||
"hover_preview_delay_2s": "Atraso de 2 segundos",
|
||||
"hover_preview_off": "Desativado",
|
||||
"show_birthday_calendar": "Calendário de aniversários",
|
||||
"show_birthday_calendar_desc": "Mostrar um calendário virtual com os aniversários dos seus contactos"
|
||||
"show_birthday_calendar_desc": "Mostrar um calendário virtual com os aniversários dos seus contatos"
|
||||
},
|
||||
"days": {
|
||||
"monday": "Segunda-feira",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"confirm_clear": "Limpar todos os eventos de \"{name}\"? Esta ação não pode ser desfeita.",
|
||||
"clear_events": "Limpar eventos",
|
||||
"events_cleared": "{count} eventos removidos",
|
||||
"error_clear": "Falha ao limpar os eventos do calendário"
|
||||
"error_clear": "Falha ao limpar os eventos do calendário",
|
||||
"share": "Compartilhar calendário",
|
||||
"new_event_in_calendar": "Novo evento neste calendário"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Assinatura iCal",
|
||||
@@ -2473,12 +2481,12 @@
|
||||
"hint": "Clique num e-mail à esquerda para começar, ou inicie o tour."
|
||||
},
|
||||
"files": {
|
||||
"title": "Ficheiros",
|
||||
"search_placeholder": "Pesquisar ficheiros...",
|
||||
"empty_state_title": "Ainda não há ficheiros",
|
||||
"empty_state_description": "Carregue ficheiros ou crie pastas para começar",
|
||||
"title": "Arquivos",
|
||||
"search_placeholder": "Pesquisar arquivos...",
|
||||
"empty_state_title": "Ainda não há arquivos",
|
||||
"empty_state_description": "Carregue arquivos ou crie pastas para começar",
|
||||
"upload": "Carregar",
|
||||
"upload_files": "Carregar ficheiros",
|
||||
"upload_files": "Carregar arquivos",
|
||||
"new_folder": "Nova pasta",
|
||||
"new_folder_name": "Nome da pasta",
|
||||
"rename": "Renomear",
|
||||
@@ -2493,13 +2501,13 @@
|
||||
"modified": "Modificado",
|
||||
"type": "Tipo",
|
||||
"folder": "Pasta",
|
||||
"file": "Ficheiro",
|
||||
"file": "Arquivo",
|
||||
"parent_directory": "Diretório superior",
|
||||
"breadcrumb_root": "Início",
|
||||
"drop_files_here": "Largue ficheiros ou pastas aqui para carregar",
|
||||
"drop_files_here": "Largue arquivos ou pastas aqui para carregar",
|
||||
"uploading": "A carregar...",
|
||||
"upload_success": "{count, plural, one {1 ficheiro carregado} other {# ficheiros carregados}}",
|
||||
"upload_error": "Falha ao carregar o ficheiro",
|
||||
"upload_success": "{count, plural, one {1 arquivo carregado} other {# arquivos carregados}}",
|
||||
"upload_error": "Falha ao carregar o arquivo",
|
||||
"create_folder_success": "Pasta criada",
|
||||
"create_folder_error": "Falha ao criar a pasta",
|
||||
"delete_success": "Eliminado com sucesso",
|
||||
@@ -2507,11 +2515,11 @@
|
||||
"rename_success": "Renomeado com sucesso",
|
||||
"rename_error": "Falha ao renomear",
|
||||
"download_error": "Falha ao transferir",
|
||||
"not_available": "O armazenamento de ficheiros não está disponível neste servidor",
|
||||
"not_available": "O armazenamento de arquivos não está disponível neste servidor",
|
||||
"cancel": "Cancelar",
|
||||
"create": "Criar",
|
||||
"save": "Guardar",
|
||||
"no_results": "Nenhum ficheiro corresponde à sua pesquisa",
|
||||
"no_results": "Nenhum arquivo corresponde à sua pesquisa",
|
||||
"batch_delete_confirm_message": "Tem a certeza de que deseja eliminar {count, plural, one {1 item} other {# itens}}? Esta ação não pode ser desfeita.",
|
||||
"batch_delete_success": "{count, plural, one {1 item eliminado} other {# itens eliminados}}",
|
||||
"grid_view": "Vista em grelha",
|
||||
@@ -2527,27 +2535,27 @@
|
||||
"move_error": "Falha ao mover",
|
||||
"paste_success": "Colado com sucesso",
|
||||
"paste_error": "Falha ao colar",
|
||||
"new_text_file": "Novo ficheiro de texto",
|
||||
"file_name": "Nome do ficheiro",
|
||||
"new_text_file": "Novo arquivo de texto",
|
||||
"file_name": "Nome do arquivo",
|
||||
"retry": "Tentar novamente",
|
||||
"refresh": "Atualizar",
|
||||
"toggle_favorite": "Alternar favorito",
|
||||
"duplicate": "Duplicar",
|
||||
"duplicate_success": "Duplicado com sucesso",
|
||||
"duplicate_error": "Falha ao duplicar",
|
||||
"create_file_success": "Ficheiro criado",
|
||||
"create_file_error": "Falha ao criar ficheiro",
|
||||
"create_file_success": "Arquivo criado",
|
||||
"create_file_error": "Falha ao criar arquivo",
|
||||
"favorites": "Favoritos",
|
||||
"recent": "Recentes",
|
||||
"properties": "Propriedades",
|
||||
"open_folder": "Abrir pasta",
|
||||
"upload_folder": "Carregar pasta",
|
||||
"file_too_large": "\"{name}\" excede o tamanho máximo do ficheiro ({max})",
|
||||
"file_too_large": "\"{name}\" excede o tamanho máximo do arquivo ({max})",
|
||||
"undo": "Desfazer",
|
||||
"undo_success": "Ação desfeita",
|
||||
"undo_error": "Falha ao desfazer",
|
||||
"toolbar": "Ações de arquivo",
|
||||
"file_list": "Ficheiros e pastas",
|
||||
"file_list": "Arquivos e pastas",
|
||||
"context_menu": "Ações",
|
||||
"settings_title": "Configurações de arquivos",
|
||||
"settings_display": "Exibição",
|
||||
@@ -2570,7 +2578,7 @@
|
||||
"settings_show_hidden": "Mostrar arquivos ocultos",
|
||||
"settings_show_hidden_desc": "Exibir arquivos e pastas que começam com um ponto",
|
||||
"settings_folder_layout": "Navegação de pastas",
|
||||
"settings_folder_layout_desc": "Escolha como as pastas são apresentadas: integradas com os ficheiros ou numa árvore na barra lateral",
|
||||
"settings_folder_layout_desc": "Escolha como as pastas são apresentadas: integradas com os arquivos ou numa árvore na barra lateral",
|
||||
"settings_folder_layout_inline": "Integrado",
|
||||
"settings_folder_layout_sidebar": "Barra lateral",
|
||||
"disabled_title": "O recurso de arquivos foi desativado pelo seu administrador",
|
||||
@@ -2695,16 +2703,39 @@
|
||||
"event_modal_desc": "Aqui está o formulário do evento. Preencha o título, escolha uma data e hora, adicione um local ou participantes. Clique em salvar quando terminar - ou feche e siga em frente.",
|
||||
"contacts_list_title": "Seus contatos",
|
||||
"contacts_list_desc": "Aqui estão seus contatos. Clique em qualquer contato para ver seus detalhes à direita. Você também pode criar novos contatos, importar vCards ou organizar contatos em grupos.",
|
||||
"files_title": "Armazenamento de ficheiros",
|
||||
"files_title": "Armazenamento de arquivos",
|
||||
"settings_tabs_title": "Menu de configurações",
|
||||
"settings_tabs_desc": "Aqui estão todas as categorias de configurações. Personalize a aparência, gerencie identidades, configure filtros de e-mail, ajuste o calendário e muito mais.",
|
||||
"files_desc": "O navegador de ficheiros permite carregar, organizar e partilhar ficheiros - como uma nuvem pessoal integrada no seu e-mail.",
|
||||
"demo_banner_title": "Controlos de demonstração",
|
||||
"files_desc": "O navegador de arquivos permite carregar, organizar e compartilhar arquivos - como uma nuvem pessoal integrada no seu e-mail.",
|
||||
"demo_banner_title": "Controles de demonstração",
|
||||
"demo_banner_desc": "Está no modo de demonstração - tudo permanece no seu navegador. Clique em 'Repor Demonstração' a qualquer momento para recomeçar com dados limpos.",
|
||||
"quota_title": "Utilização do armazenamento",
|
||||
"quota_desc": "Acompanhe o tamanho da sua caixa de correio aqui. O círculo preenche-se à medida que utiliza mais espaço."
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "A pesquisa não está disponível na vista unificada"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Compartilhar \"{name}\"",
|
||||
"description": "Conceda acesso a outros usuários ou grupos neste servidor. As alterações têm efeito imediato.",
|
||||
"no_shares": "Ainda não compartilhado.",
|
||||
"add_person": "Adicionar pessoa ou grupo",
|
||||
"search_placeholder": "Buscar por nome ou e-mail…",
|
||||
"loading_principals": "Carregando usuários…",
|
||||
"no_principals": "Nenhum outro usuário ou grupo encontrado.",
|
||||
"no_match": "Sem resultados.",
|
||||
"remove": "Remover acesso",
|
||||
"group": "Grupo",
|
||||
"share_added": "Acesso concedido",
|
||||
"share_updated": "Acesso atualizado",
|
||||
"share_removed": "Acesso removido",
|
||||
"share_failed": "Falha ao atualizar o compartilhamento",
|
||||
"preset": {
|
||||
"freeBusy": "Apenas disponibilidade",
|
||||
"read": "Somente leitura",
|
||||
"readWrite": "Leitura e escrita",
|
||||
"manager": "Gerente",
|
||||
"custom": "Personalizado"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-2
@@ -1815,7 +1815,13 @@
|
||||
"renamed": "Адресная книга переименована",
|
||||
"rename_failed": "Не удалось переименовать адресную книгу",
|
||||
"default": "По умолчанию",
|
||||
"manage": "Управление адресными книгами"
|
||||
"manage": "Управление адресными книгами",
|
||||
"share": "Поделиться адресной книгой",
|
||||
"new_contact_in_book": "Новый контакт в этой адресной книге",
|
||||
"delete": "Удалить адресную книгу",
|
||||
"confirm_delete": "Удалить «{name}»? Все контакты в этой адресной книге будут удалены.",
|
||||
"deleted": "Адресная книга удалена",
|
||||
"delete_failed": "Не удалось удалить адресную книгу"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Адреса электронной почты",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"error_delete": "Не удалось удалить календарь",
|
||||
"caldav_url": "URL CalDAV",
|
||||
"copy_url": "Скопировать CalDAV URL",
|
||||
"url_copied": "CalDAV URL скопирован в буфер обмена"
|
||||
"url_copied": "CalDAV URL скопирован в буфер обмена",
|
||||
"share": "Поделиться календарём",
|
||||
"new_event_in_calendar": "Новое событие в этом календаре"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Подписка iCal",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Поиск недоступен в объединённом представлении"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Поделиться «{name}»",
|
||||
"description": "Предоставьте доступ другим пользователям или группам на этом сервере. Изменения вступают в силу немедленно.",
|
||||
"no_shares": "Пока никому не предоставлен доступ.",
|
||||
"add_person": "Добавить пользователя или группу",
|
||||
"search_placeholder": "Искать по имени или email…",
|
||||
"loading_principals": "Загрузка пользователей…",
|
||||
"no_principals": "Других пользователей или групп не найдено.",
|
||||
"no_match": "Нет совпадений.",
|
||||
"remove": "Отозвать доступ",
|
||||
"group": "Группа",
|
||||
"share_added": "Доступ предоставлен",
|
||||
"share_updated": "Доступ обновлён",
|
||||
"share_removed": "Доступ отозван",
|
||||
"share_failed": "Не удалось обновить общий доступ",
|
||||
"preset": {
|
||||
"freeBusy": "Только занятость",
|
||||
"read": "Только чтение",
|
||||
"readWrite": "Чтение и запись",
|
||||
"manager": "Управляющий",
|
||||
"custom": "Пользовательский"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-2
@@ -1815,7 +1815,13 @@
|
||||
"renamed": "Адресну книгу перейменовано",
|
||||
"rename_failed": "Не вдалося перейменувати адресну книгу",
|
||||
"default": "За замовчуванням",
|
||||
"manage": "Керуйте адресними книгами"
|
||||
"manage": "Керуйте адресними книгами",
|
||||
"share": "Поділитися адресною книгою",
|
||||
"new_contact_in_book": "Новий контакт у цій адресній книзі",
|
||||
"delete": "Видалити адресну книгу",
|
||||
"confirm_delete": "Видалити «{name}»? Усі контакти в цій адресній книзі будуть видалені.",
|
||||
"deleted": "Адресну книгу видалено",
|
||||
"delete_failed": "Не вдалося видалити адресну книгу"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Адреси електронної пошти",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"error_delete": "Не вдалося видалити календар",
|
||||
"caldav_url": "URL-адреса CalDAV",
|
||||
"copy_url": "Скопіюйте URL-адресу CalDAV",
|
||||
"url_copied": "URL-адресу CalDAV скопійовано в буфер обміну"
|
||||
"url_copied": "URL-адресу CalDAV скопійовано в буфер обміну",
|
||||
"share": "Поділитися календарем",
|
||||
"new_event_in_calendar": "Нова подія в цьому календарі"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "Підписка iCal",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "Пошук недоступний в об'єднаному перегляді"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "Поділитися «{name}»",
|
||||
"description": "Надайте доступ іншим користувачам або групам на цьому сервері. Зміни набувають чинності негайно.",
|
||||
"no_shares": "Поки що ні з ким не поділено.",
|
||||
"add_person": "Додати людину або групу",
|
||||
"search_placeholder": "Шукати за іменем або email…",
|
||||
"loading_principals": "Завантаження користувачів…",
|
||||
"no_principals": "Інших користувачів або груп не знайдено.",
|
||||
"no_match": "Збігів немає.",
|
||||
"remove": "Видалити доступ",
|
||||
"group": "Група",
|
||||
"share_added": "Доступ надано",
|
||||
"share_updated": "Доступ оновлено",
|
||||
"share_removed": "Доступ видалено",
|
||||
"share_failed": "Не вдалося оновити спільний доступ",
|
||||
"preset": {
|
||||
"freeBusy": "Лише зайнятість",
|
||||
"read": "Лише читання",
|
||||
"readWrite": "Читання та запис",
|
||||
"manager": "Керівник",
|
||||
"custom": "Власне"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-2
@@ -1815,7 +1815,13 @@
|
||||
"renamed": "地址簿已重命名",
|
||||
"rename_failed": "重命名地址簿失败",
|
||||
"default": "默认",
|
||||
"manage": "管理地址簿"
|
||||
"manage": "管理地址簿",
|
||||
"share": "共享通讯录",
|
||||
"new_contact_in_book": "在此通讯录中新建联系人",
|
||||
"delete": "删除通讯录",
|
||||
"confirm_delete": "删除「{name}」?此通讯录中的所有联系人将被移除。",
|
||||
"deleted": "通讯录已删除",
|
||||
"delete_failed": "删除通讯录失败"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "邮箱地址",
|
||||
@@ -2340,7 +2346,9 @@
|
||||
"error_delete": "删除日历失败",
|
||||
"caldav_url": "CalDAV URL",
|
||||
"copy_url": "复制 CalDAV URL",
|
||||
"url_copied": "CalDAV URL 已复制到剪贴板"
|
||||
"url_copied": "CalDAV URL 已复制到剪贴板",
|
||||
"share": "共享日历",
|
||||
"new_event_in_calendar": "在此日历中新建事件"
|
||||
},
|
||||
"subscription": {
|
||||
"title": "iCal 订阅",
|
||||
@@ -2706,5 +2714,28 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"search_unavailable": "统一视图中无法使用搜索"
|
||||
},
|
||||
"sharing": {
|
||||
"title": "共享「{name}」",
|
||||
"description": "向此服务器上的其他用户或群组授予访问权限。更改会立即生效。",
|
||||
"no_shares": "尚未共享。",
|
||||
"add_person": "添加用户或群组",
|
||||
"search_placeholder": "按姓名或邮箱搜索…",
|
||||
"loading_principals": "正在加载用户…",
|
||||
"no_principals": "未找到其他用户或群组。",
|
||||
"no_match": "无匹配项。",
|
||||
"remove": "取消访问",
|
||||
"group": "群组",
|
||||
"share_added": "已授予访问权限",
|
||||
"share_updated": "已更新访问权限",
|
||||
"share_removed": "已取消访问权限",
|
||||
"share_failed": "更新共享失败",
|
||||
"preset": {
|
||||
"freeBusy": "仅显示忙/闲",
|
||||
"read": "只读",
|
||||
"readWrite": "读写",
|
||||
"manager": "管理员",
|
||||
"custom": "自定义"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.5.1",
|
||||
"version": "1.5.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.5.1",
|
||||
"version": "1.5.2",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.5.1",
|
||||
"version": "1.5.2",
|
||||
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||
"license": "AGPL-3.0-only",
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import createIntlMiddleware from "next-intl/middleware";
|
||||
import { routing } from "./i18n/routing";
|
||||
import { getEnabledPluginFrameOrigins } from "./lib/admin/csp-frame-origins";
|
||||
|
||||
const intlMiddleware = createIntlMiddleware(routing);
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
// Next 16's Proxy always runs on Node.js runtime and route-segment config
|
||||
// (e.g. `export const config = { matcher }`) is no longer allowed in the
|
||||
// proxy file. We replicate the previous matcher inline by short-circuiting
|
||||
// requests for API routes, Next internals and static assets.
|
||||
const PROXY_SKIP_PATTERN = /^\/(?:api|_next)(?:\/|$)|\.[^/]+$/;
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
if (PROXY_SKIP_PATTERN.test(request.nextUrl.pathname)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const nonce = crypto.randomUUID();
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
@@ -16,6 +27,14 @@ export function proxy(request: NextRequest) {
|
||||
|
||||
const frameAncestors = process.env.ALLOWED_FRAME_ANCESTORS?.trim() || "'none'";
|
||||
|
||||
// Plugins may declare iframe origins they need (e.g. for embedded video).
|
||||
// Each origin is validated at install time and re-validated here.
|
||||
const pluginFrameOrigins = await getEnabledPluginFrameOrigins();
|
||||
const frameSrc =
|
||||
pluginFrameOrigins.length > 0
|
||||
? `frame-src 'self' blob: ${pluginFrameOrigins.join(" ")}`
|
||||
: `frame-src 'self' blob:`;
|
||||
|
||||
const csp = [
|
||||
`default-src 'self'`,
|
||||
`script-src ${scriptSrc}`,
|
||||
@@ -23,7 +42,7 @@ export function proxy(request: NextRequest) {
|
||||
`img-src 'self' data: blob: https:`,
|
||||
`font-src 'self'`,
|
||||
`connect-src ${connectSrc}`,
|
||||
`frame-src 'self' blob:`,
|
||||
frameSrc,
|
||||
`object-src 'none'`,
|
||||
`base-uri 'self'`,
|
||||
`form-action 'self'`,
|
||||
@@ -78,7 +97,3 @@ export function proxy(request: NextRequest) {
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api|_next|.*\\..*).*)"],
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
|
||||
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights } from '@/lib/jmap/types';
|
||||
import { debug } from '@/lib/debug';
|
||||
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
||||
import { parseDuration } from '@/components/calendar/event-card';
|
||||
@@ -125,6 +125,7 @@ interface CalendarStore {
|
||||
rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null) => Promise<void>;
|
||||
importEvents: (client: IJMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
|
||||
updateCalendar: (client: IJMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
|
||||
shareCalendar: (client: IJMAPClient, calendarId: string, principalId: string, rights: CalendarRights | null) => Promise<void>;
|
||||
createCalendar: (client: IJMAPClient, calendar: Partial<Calendar>) => Promise<Calendar | null>;
|
||||
removeCalendar: (client: IJMAPClient, calendarId: string) => Promise<void>;
|
||||
clearCalendarEvents: (client: IJMAPClient, calendarId: string) => Promise<number>;
|
||||
@@ -653,6 +654,29 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
shareCalendar: async (client, calendarId, principalId, rights) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
const cal = get().calendars.find(c => c.id === calendarId);
|
||||
const realId = cal?.originalId || calendarId;
|
||||
const targetAccountId = cal?.accountId;
|
||||
await client.setCalendarShare(realId, principalId, rights, targetAccountId);
|
||||
set((state) => ({
|
||||
calendars: state.calendars.map(c => {
|
||||
if (c.id !== calendarId) return c;
|
||||
const next = { ...(c.shareWith ?? {}) };
|
||||
if (rights === null) delete next[principalId];
|
||||
else next[principalId] = rights;
|
||||
return { ...c, shareWith: next };
|
||||
}),
|
||||
}));
|
||||
} catch (error) {
|
||||
debug.error('Failed to share calendar:', error);
|
||||
set({ error: 'Failed to share calendar' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
createCalendar: async (client, calendar) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
|
||||
+42
-1
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
|
||||
import type { ContactCard, AddressBook, AddressBookRights, ContactName } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { debug } from '@/lib/debug';
|
||||
@@ -101,6 +101,8 @@ interface ContactStore {
|
||||
bulkAddToGroup: (client: IJMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
|
||||
moveContactToAddressBook: (client: IJMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
|
||||
renameAddressBook: (client: IJMAPClient, addressBook: AddressBook, newName: string) => Promise<void>;
|
||||
removeAddressBook: (client: IJMAPClient, addressBook: AddressBook) => Promise<void>;
|
||||
shareAddressBook: (client: IJMAPClient, addressBook: AddressBook, principalId: string, rights: AddressBookRights | null) => Promise<void>;
|
||||
renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise<void>;
|
||||
|
||||
importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise<number>;
|
||||
@@ -655,6 +657,45 @@ export const useContactStore = create<ContactStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
removeAddressBook: async (client, addressBook) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
const originalId = addressBook.originalId || addressBook.id;
|
||||
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
|
||||
await client.deleteAddressBook(originalId, accountId);
|
||||
set((state) => ({
|
||||
addressBooks: state.addressBooks.filter(b => b.id !== addressBook.id),
|
||||
contacts: state.contacts.filter(c => !c.addressBookIds?.[addressBook.id]),
|
||||
}));
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to delete address book';
|
||||
set({ error: msg });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
shareAddressBook: async (client, addressBook, principalId, rights) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
const originalId = addressBook.originalId || addressBook.id;
|
||||
const accountId = addressBook.isShared ? addressBook.accountId : undefined;
|
||||
await client.setAddressBookShare(originalId, principalId, rights, accountId);
|
||||
set((state) => ({
|
||||
addressBooks: state.addressBooks.map(b => {
|
||||
if (b.id !== addressBook.id) return b;
|
||||
const next = { ...(b.shareWith ?? {}) };
|
||||
if (rights === null) delete next[principalId];
|
||||
else next[principalId] = rights;
|
||||
return { ...b, shareWith: next };
|
||||
}),
|
||||
}));
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to share address book';
|
||||
set({ error: msg });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
renameKeyword: async (client, oldKeyword, newKeyword) => {
|
||||
set({ error: null });
|
||||
const oldKw = oldKeyword.trim();
|
||||
|
||||
@@ -20,7 +20,7 @@ import { apiFetch } from '@/lib/browser-navigation';
|
||||
// ─── Slot State ──────────────────────────────────────────────
|
||||
|
||||
const SLOT_NAMES: SlotName[] = [
|
||||
'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar',
|
||||
'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar', 'composer-sidebar', 'composer-sidebar-right',
|
||||
'sidebar-widget', 'email-detail-sidebar', 'settings-section', 'context-menu-email', 'navigation-rail-bottom',
|
||||
'calendar-event-actions', 'admin-plugin-page',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user