mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
Merge branch 'feat/enterprise-pack-b'
This commit is contained in:
@@ -27,6 +27,7 @@ import { PushNotificationPrompt } from "@/components/push-notification-prompt";
|
||||
import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt";
|
||||
import { SessionTimeoutGuard } from "@/components/session/session-timeout-guard";
|
||||
import { AccountTemplateSync } from "@/components/templates/account-template-sync";
|
||||
import { FreeBusyAutoShare } from "@/components/calendar/free-busy-auto-share";
|
||||
import { IS_LITE } from "@/lib/lite";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { locales } from "@/i18n/routing";
|
||||
@@ -76,6 +77,7 @@ export default async function LocaleLayout({
|
||||
<PushBindingsHost />
|
||||
<SessionTimeoutGuard />
|
||||
<AccountTemplateSync />
|
||||
<FreeBusyAutoShare />
|
||||
{children}
|
||||
<PluginDialogHost />
|
||||
<ExternalRecipientConfirmHost />
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// Company calendar addresses can hold a token: users get the feeds without
|
||||
// them (the browser fetches a feed by id), the admin pages with them.
|
||||
|
||||
const who = vi.hoisted(() => ({
|
||||
identity: null as null | { admin: true } | { admin: false; addresses: string[] },
|
||||
adminCookie: false,
|
||||
}));
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: async () => ({ get: (name: string) => (name === 'admin_session' && who.adminCookie ? { value: 'token' } : undefined) }),
|
||||
}));
|
||||
vi.mock('@/lib/auth/instance-details', () => ({
|
||||
canSeeInstanceDetails: async () => true,
|
||||
signedInIdentity: async () => who.identity,
|
||||
}));
|
||||
vi.mock('@/lib/auth/bearer-identity', () => ({ bearerIdentity: async () => null }));
|
||||
|
||||
const FEED = { id: 'hol', name: 'Holidays', url: 'https://cal.example.org/h.ics?token=secret', color: '#2563eb', refreshMinutes: 60 };
|
||||
const TEAM_FEED = { id: 'team', name: 'Team', url: 'https://intranet.example.org/team.ics?key=k', color: '#16a34a', refreshMinutes: 120 };
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: async () => {},
|
||||
getPolicy: () => ({
|
||||
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
|
||||
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
|
||||
organisation: { calendars: { feeds: [FEED], freeBusyGroup: '' } },
|
||||
scopes: [{ id: 'team', label: 'Team', match: { domains: ['example.org'] }, policy: { organisation: { calendars: { feeds: [TEAM_FEED] } } } }],
|
||||
}),
|
||||
getPolicyWithVersion() { return { policy: this.getPolicy(), version: 'v1' }; },
|
||||
},
|
||||
}));
|
||||
vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } }));
|
||||
vi.mock('@/lib/admin/session', () => ({
|
||||
requireAdminAuth: vi.fn(),
|
||||
getClientIP: () => '0.0.0.0',
|
||||
verifyCurrentAdminSession: async (token: string) => (token === 'token' ? { admin: true } : null),
|
||||
}));
|
||||
vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn() }));
|
||||
|
||||
import { GET } from '@/app/api/admin/policy/route';
|
||||
|
||||
const get = async () => (await GET(new NextRequest('https://mail.example/api/admin/policy'))).json();
|
||||
|
||||
beforeEach(() => {
|
||||
who.identity = null;
|
||||
who.adminCookie = false;
|
||||
});
|
||||
|
||||
describe('GET /api/admin/policy company calendar addresses', () => {
|
||||
it('gives a signed-in user every feed without its address, in Everyone and in the scopes', async () => {
|
||||
who.identity = { admin: false, addresses: ['alice@example.org'] };
|
||||
const body = await get();
|
||||
expect(body.organisation.calendars.feeds).toEqual([{ ...FEED, url: '', urlHeld: true }]);
|
||||
expect(body.scopes[0].policy.organisation.calendars.feeds).toEqual([{ ...TEAM_FEED, url: '', urlHeld: true }]);
|
||||
expect(JSON.stringify(body)).not.toContain('token=secret');
|
||||
expect(JSON.stringify(body)).not.toContain('key=k');
|
||||
});
|
||||
|
||||
it('gives the admin pages the addresses', async () => {
|
||||
who.identity = { admin: true };
|
||||
const body = await get();
|
||||
expect(body.organisation.calendars.feeds[0].url).toBe(FEED.url);
|
||||
expect(body.scopes[0].policy.organisation.calendars.feeds[0].url).toBe(TEAM_FEED.url);
|
||||
});
|
||||
|
||||
// No session secret: the server can't tell users apart (signedInIdentity null).
|
||||
it('keeps the addresses back without a session secret unless the admin dashboard asks', async () => {
|
||||
const anyone = await get();
|
||||
expect(JSON.stringify(anyone)).not.toContain('token=secret');
|
||||
expect(JSON.stringify(anyone)).not.toContain('key=k');
|
||||
expect(anyone.organisation.calendars.feeds[0]).toMatchObject({ id: 'hol', url: '', urlHeld: true });
|
||||
who.adminCookie = true;
|
||||
const admin = await get();
|
||||
expect(admin.organisation.calendars.feeds[0].url).toBe(FEED.url);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,16 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { completePolicy, configManager, isPolicyConflict } from '@/lib/admin/config-manager';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { cookies } from 'next/headers';
|
||||
import { requireAdminAuth, getClientIP, verifyCurrentAdminSession } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { POLICY_DOMAINS_HEADER, POLICY_SCOPE_HEADER, type SettingsPolicy } from '@/lib/admin/types';
|
||||
import { ADMIN_SESSION_COOKIE, POLICY_DOMAINS_HEADER, POLICY_SCOPE_HEADER, type SettingsPolicy } from '@/lib/admin/types';
|
||||
import { canSeeInstanceDetails, signedInIdentity } from '@/lib/auth/instance-details';
|
||||
import { bearerIdentity } from '@/lib/auth/bearer-identity';
|
||||
import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable';
|
||||
import { scopesVisibleTo } from '@/lib/admin/policy-scopes';
|
||||
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
|
||||
import { stripFeedUrls } from '@/lib/admin/company-feeds';
|
||||
|
||||
/** The policy version as an HTTP entity tag. */
|
||||
function policyETag(version: string): string {
|
||||
@@ -70,6 +72,14 @@ export async function GET(request: NextRequest) {
|
||||
// answers with all of them (documented).
|
||||
const identity = await signedInIdentity();
|
||||
if (identity !== null && !identity.admin) return userPolicyResponse(policy, identity.addresses);
|
||||
// Without a session secret only the admin dashboard's own session tells
|
||||
// the admin apart; everyone else gets the feeds without their addresses,
|
||||
// which may hold tokens.
|
||||
if (identity === null && !(await hasAdminSession())) {
|
||||
return NextResponse.json({ ...stripFeedUrls(policy), scopes: stripFeedUrls(policy).scopes ?? [] }, {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
}
|
||||
// The whole policy, as the admin pages edit it: with its version, which
|
||||
// they send back as If-Match when they save.
|
||||
return NextResponse.json({ ...policy, scopes: policy.scopes ?? [] }, {
|
||||
@@ -83,8 +93,22 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The policy for a signed-in user who is not the admin, with the addresses they sign in with. */
|
||||
function userPolicyResponse(policy: SettingsPolicy, addresses: string[]): NextResponse {
|
||||
async function hasAdminSession(): Promise<boolean> {
|
||||
try {
|
||||
const token = (await cookies()).get(ADMIN_SESSION_COOKIE)?.value;
|
||||
return !!token && (await verifyCurrentAdminSession(token)) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The policy for a signed-in user who is not the admin, with the addresses
|
||||
* they sign in with. Company calendar addresses stay here: the browser
|
||||
* fetches a feed by id through /api/fetch-ical (lib/admin/company-feeds.ts).
|
||||
*/
|
||||
function userPolicyResponse(raw: SettingsPolicy, addresses: string[]): NextResponse {
|
||||
const policy = stripFeedUrls(raw);
|
||||
const scopes = scopesVisibleTo(policy.scopes ?? [], addresses);
|
||||
// Which domains the scopes were picked for, so a browser that signs in
|
||||
// to an account of another domain knows to fetch again.
|
||||
|
||||
+157
-60
@@ -1,8 +1,13 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { getStalwartCredentials, type StalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { DisallowedUrlError, fetchPublicUrl, type PublicFetchResponse } from '@/lib/security/url-guard';
|
||||
import { readBodyCapped } from '@/lib/security/capped-body';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { feedForSubject, intranetFeedHost } from '@/lib/admin/company-feeds';
|
||||
import { companyFeedCache, type FeedResult } from '@/lib/admin/company-feed-cache';
|
||||
import type { CompanyFeed } from '@/lib/admin/organisation-policy';
|
||||
import { serverPolicySubject } from '@/lib/admin/server-policy-subject';
|
||||
|
||||
const DEFAULT_MAX_BYTES = 25 * 1024 * 1024; // 25MB
|
||||
// Base budget for a default-sized feed; scaled up with the configured cap so a
|
||||
@@ -48,43 +53,25 @@ function extractBasicAuth(rawUrl: string): { cleanUrl: string; authHeader: strin
|
||||
return { cleanUrl: parsed.toString(), authHeader };
|
||||
}
|
||||
|
||||
type FetchOutcome =
|
||||
| { kind: 'ok'; buffer: ArrayBuffer; etag: string | null; lastModified: string | null }
|
||||
| { kind: 'not-modified' }
|
||||
| { kind: 'error'; response: NextResponse };
|
||||
|
||||
/**
|
||||
* POST /api/fetch-ical
|
||||
*
|
||||
* Server-side proxy for iCalendar subscription URLs (the browser cannot fetch
|
||||
* them directly because of CORS). Only logged-in users may use it, and every
|
||||
* hop - including redirect targets - goes through `fetchPublicUrl`, which
|
||||
* validates the resolved address inside the socket lookup so a rebinding DNS
|
||||
* server cannot steer the connection at an internal host
|
||||
* (GHSA-24w9-8r42-8jwm).
|
||||
* Fetch one feed. Every hop, redirect targets included, goes through
|
||||
* `fetchPublicUrl`, which validates the resolved address inside the socket
|
||||
* lookup so a rebinding DNS server cannot steer the connection at an
|
||||
* internal host (GHSA-24w9-8r42-8jwm). `allowPrivateHost` lifts that for the
|
||||
* one hostname of a company calendar the admin entered (an intranet host).
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
// CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's
|
||||
// session cookie, which SameSite=Lax still sends from a same-site page.
|
||||
const crossOrigin = rejectCrossOriginRequest(request);
|
||||
if (crossOrigin) return crossOrigin;
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: { url?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { url } = body;
|
||||
|
||||
if (!url || typeof url !== 'string') {
|
||||
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
async function fetchIcal(
|
||||
url: string,
|
||||
options: { allowPrivateHost?: string; etag?: string | null; lastModified?: string | null; requireCalendar?: boolean } = {},
|
||||
): Promise<FetchOutcome> {
|
||||
const fail = (error: string, status: number): FetchOutcome => ({ kind: 'error', response: NextResponse.json({ error }, { status }) });
|
||||
const extracted = extractBasicAuth(url);
|
||||
if (!extracted) {
|
||||
return NextResponse.json({ error: 'Invalid or disallowed URL' }, { status: 400 });
|
||||
}
|
||||
if (!extracted) return fail('Invalid or disallowed URL', 400);
|
||||
|
||||
const { cleanUrl, authHeader } = extracted;
|
||||
|
||||
@@ -106,26 +93,30 @@ export async function POST(request: NextRequest) {
|
||||
if (authHeader && new URL(currentUrl).origin === originalOrigin) {
|
||||
headers['Authorization'] = authHeader;
|
||||
}
|
||||
if (options.etag) headers['If-None-Match'] = options.etag;
|
||||
if (options.lastModified) headers['If-Modified-Since'] = options.lastModified;
|
||||
|
||||
try {
|
||||
response = await fetchPublicUrl(currentUrl, {
|
||||
signal: controller.signal,
|
||||
headers,
|
||||
});
|
||||
const init = { signal: controller.signal, headers };
|
||||
// The intranet exception holds for the admin's own address only, not
|
||||
// for a redirect to another port or host.
|
||||
response = options.allowPrivateHost && new URL(currentUrl).origin === originalOrigin
|
||||
? await fetchPublicUrl(currentUrl, init, { allowPrivateHost: options.allowPrivateHost })
|
||||
: await fetchPublicUrl(currentUrl, init);
|
||||
} catch (error) {
|
||||
if (error instanceof DisallowedUrlError) {
|
||||
return NextResponse.json(
|
||||
{ error: i === 0 ? 'Invalid or disallowed URL' : 'Redirect to disallowed URL' },
|
||||
{ status: 400 },
|
||||
);
|
||||
return fail(i === 0 ? 'Invalid or disallowed URL' : 'Redirect to disallowed URL', 400);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (response.status === 304 && (options.etag || options.lastModified)) {
|
||||
return { kind: 'not-modified' };
|
||||
}
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('location');
|
||||
if (!location) {
|
||||
return NextResponse.json({ error: 'Redirect without Location header' }, { status: 502 });
|
||||
return fail('Redirect without Location header', 502);
|
||||
}
|
||||
currentUrl = new URL(location, currentUrl).toString();
|
||||
continue;
|
||||
@@ -134,15 +125,12 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (!response || !response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Remote server returned ${response?.status ?? 'unknown'}` },
|
||||
{ status: 502 }
|
||||
);
|
||||
return fail(`Remote server returned ${response?.status ?? 'unknown'}`, 502);
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength && parseInt(contentLength) > maxResponseSize) {
|
||||
return tooLarge(maxResponseSize);
|
||||
return { kind: 'error', response: tooLarge(maxResponseSize) };
|
||||
}
|
||||
|
||||
// Read up to the cap and no further: a chunked body with no
|
||||
@@ -151,22 +139,131 @@ export async function POST(request: NextRequest) {
|
||||
// memory with it.
|
||||
const buffer = await readBodyCapped(response, maxResponseSize, controller);
|
||||
if (!buffer) {
|
||||
return tooLarge(maxResponseSize);
|
||||
return { kind: 'error', response: tooLarge(maxResponseSize) };
|
||||
}
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/calendar',
|
||||
'Content-Length': buffer.byteLength.toString(),
|
||||
},
|
||||
});
|
||||
if (options.requireCalendar && !looksLikeCalendar(buffer)) {
|
||||
return fail('The address did not answer with a calendar', 502);
|
||||
}
|
||||
return { kind: 'ok', buffer, etag: response.headers.get('etag'), lastModified: response.headers.get('last-modified') };
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return NextResponse.json({ error: 'Request timed out' }, { status: 504 });
|
||||
return fail('Request timed out', 504);
|
||||
}
|
||||
return NextResponse.json({ error: 'Failed to fetch calendar' }, { status: 502 });
|
||||
return fail('Failed to fetch calendar', 502);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts with BEGIN:VCALENDAR (after a byte order mark and blank space). */
|
||||
function looksLikeCalendar(buffer: ArrayBuffer): boolean {
|
||||
// TextDecoder drops a byte order mark; trimStart the rest.
|
||||
const head = new TextDecoder().decode(buffer.slice(0, 512)).trimStart();
|
||||
return /^BEGIN:VCALENDAR/i.test(head);
|
||||
}
|
||||
|
||||
function icalResponse(buffer: ArrayBuffer): NextResponse {
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/calendar',
|
||||
'Content-Length': buffer.byteLength.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A company calendar by id (organisation.calendars.feeds). The address comes
|
||||
* from the caller's own scoped policy, with their groups read from their
|
||||
* JMAP session here, so the browser never sees it and can't ask for the
|
||||
* feed of a scope it isn't in. A feed is kept per address for its refresh
|
||||
* interval, so many users cost one fetch, and asked again with
|
||||
* If-None-Match / If-Modified-Since. A feed that can't be fetched keeps
|
||||
* answering with what it last had.
|
||||
*/
|
||||
async function fetchCompanyFeed(creds: StalwartCredentials, feedId: string): Promise<NextResponse> {
|
||||
await configManager.ensureLoaded();
|
||||
const subject = await serverPolicySubject(creds);
|
||||
const feed = feedForSubject(configManager.getPolicy(), subject, feedId);
|
||||
if (!feed) {
|
||||
return NextResponse.json({ error: 'Unknown calendar' }, { status: 404 });
|
||||
}
|
||||
const cached = companyFeedCache.get(feed.url);
|
||||
if (cached && Date.now() - cached.fetchedAt < feed.refreshMinutes * 60_000) {
|
||||
return icalResponse(cached.body);
|
||||
}
|
||||
// A feed that just failed isn't asked again for a minute; requests that
|
||||
// arrive while it is being fetched wait for that one fetch.
|
||||
const failed = companyFeedCache.failure(feed.url);
|
||||
const result = failed ?? await companyFeedCache.once(feed.url, () => refreshFeed(feed));
|
||||
if ('body' in result) return icalResponse(result.body);
|
||||
return NextResponse.json({ error: result.error }, { status: result.status });
|
||||
}
|
||||
|
||||
/** Fetch a company feed into the cache: its body, or why there is none. */
|
||||
async function refreshFeed(feed: CompanyFeed): Promise<FeedResult> {
|
||||
const cached = companyFeedCache.get(feed.url);
|
||||
const outcome = await fetchIcal(feed.url, {
|
||||
allowPrivateHost: intranetFeedHost(feed) ?? undefined,
|
||||
requireCalendar: true,
|
||||
etag: cached?.etag,
|
||||
lastModified: cached?.lastModified,
|
||||
});
|
||||
if (outcome.kind === 'ok') {
|
||||
companyFeedCache.set(feed.url, { body: outcome.buffer, etag: outcome.etag, lastModified: outcome.lastModified, fetchedAt: Date.now() });
|
||||
return { body: outcome.buffer };
|
||||
}
|
||||
if (cached) {
|
||||
// A feed that can't be fetched keeps answering with what it last had.
|
||||
if (outcome.kind === 'not-modified') companyFeedCache.set(feed.url, { ...cached, fetchedAt: Date.now() });
|
||||
return { body: cached.body };
|
||||
}
|
||||
const failure = outcome.kind === 'error'
|
||||
? { status: outcome.response.status, error: String(((await outcome.response.json().catch(() => ({}))) as { error?: unknown }).error ?? 'Failed to fetch calendar') }
|
||||
: { status: 502, error: 'Failed to fetch calendar' };
|
||||
companyFeedCache.fail(feed.url, failure);
|
||||
return failure;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/fetch-ical
|
||||
*
|
||||
* Server-side proxy for iCalendar subscription URLs (the browser cannot fetch
|
||||
* them directly because of CORS), `{ url }`, and for company calendars by
|
||||
* id, `{ feedId }`. Only logged-in users may use it.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
// CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's
|
||||
// session cookie, which SameSite=Lax still sends from a same-site page.
|
||||
const crossOrigin = rejectCrossOriginRequest(request);
|
||||
if (crossOrigin) return crossOrigin;
|
||||
const creds = await getStalwartCredentials(request);
|
||||
if (!creds) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: { url?: string; feedId?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body && body.feedId !== undefined) {
|
||||
if (typeof body.feedId !== 'string' || !body.feedId) {
|
||||
return NextResponse.json({ error: 'Unknown calendar' }, { status: 404 });
|
||||
}
|
||||
return fetchCompanyFeed(creds, body.feedId);
|
||||
}
|
||||
|
||||
const { url } = body ?? {};
|
||||
|
||||
if (!url || typeof url !== 'string') {
|
||||
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const outcome = await fetchIcal(url);
|
||||
if (outcome.kind === 'ok') return icalResponse(outcome.buffer);
|
||||
if (outcome.kind === 'error') return outcome.response;
|
||||
return NextResponse.json({ error: 'Failed to fetch calendar' }, { status: 502 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
// Organisation > Calendars > Company calendars: the feeds list
|
||||
// (organisation.calendars.feeds), per scope, with the notes on Lite and the
|
||||
// upload limit.
|
||||
|
||||
let stored: Record<string, unknown> = {};
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'PUT') {
|
||||
stored = JSON.parse(String(init.body));
|
||||
return new Response('{"ok":true}');
|
||||
}
|
||||
return new Response(JSON.stringify(stored));
|
||||
}),
|
||||
withBasePath: (p: string) => p,
|
||||
getPathPrefix: () => '',
|
||||
}));
|
||||
|
||||
import { CompanyCalendarsSection } from '../sections/company-calendars-section';
|
||||
import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft';
|
||||
import { sanitizeOrganisationPolicy } from '@/lib/admin/organisation-policy';
|
||||
|
||||
const POLICY = {
|
||||
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
|
||||
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
|
||||
organisation: { calendars: { feeds: [{ id: 'hol', name: 'Holidays', url: 'https://cal.example.org/h.ics', color: '#16a34a', refreshMinutes: 60 }], freeBusyGroup: '' } },
|
||||
scopes: [{ id: 'team', label: 'Team', match: { groups: ['ent-team@example.org'] }, policy: {} }],
|
||||
};
|
||||
|
||||
let latest: ReturnType<typeof useOrgPolicyDraft> | null = null;
|
||||
function Harness() {
|
||||
const draft = useOrgPolicyDraft('test');
|
||||
latest = draft;
|
||||
if (draft.loading) return null;
|
||||
return <CompanyCalendarsSection draft={draft} />;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stored = JSON.parse(JSON.stringify(POLICY));
|
||||
latest = null;
|
||||
useOrgScopeStore.setState({ scopeId: EVERYONE });
|
||||
});
|
||||
|
||||
describe('Company calendars section', () => {
|
||||
it('adds a feed with its name, address and colour', async () => {
|
||||
render(<Harness />);
|
||||
await waitFor(() => expect(screen.getAllByTestId('org-feed-item')).toHaveLength(1));
|
||||
fireEvent.click(screen.getByTestId('org-feed-add'));
|
||||
const names = screen.getAllByTestId('org-feed-name');
|
||||
fireEvent.change(names[1], { target: { value: 'Office closures' } });
|
||||
expect(screen.getByText(/Enter the address of the ICS feed/)).toBeInTheDocument();
|
||||
fireEvent.change(screen.getAllByTestId('org-feed-url')[1], { target: { value: 'webcal://intranet.example.org/closed.ics' } });
|
||||
await act(async () => { await latest!.save(); });
|
||||
const saved = sanitizeOrganisationPolicy(stored.organisation).calendars.feeds;
|
||||
expect(saved.map((f) => [f.name, f.url, f.refreshMinutes])).toEqual([
|
||||
['Holidays', 'https://cal.example.org/h.ics', 60],
|
||||
['Office closures', 'https://intranet.example.org/closed.ics', 60],
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds a scope\'s feeds to that scope, showing Everyone\'s as inherited', async () => {
|
||||
render(<Harness />);
|
||||
await waitFor(() => expect(screen.getAllByTestId('org-feed-item')).toHaveLength(1));
|
||||
act(() => latest!.setScopeId('team'));
|
||||
expect(screen.getByTestId('org-feed-inherited')).toHaveTextContent('Holidays');
|
||||
fireEvent.click(screen.getByTestId('org-feed-add'));
|
||||
fireEvent.change(screen.getByTestId('org-feed-name'), { target: { value: 'Team' } });
|
||||
fireEvent.change(screen.getByTestId('org-feed-url'), { target: { value: 'https://cal.example.org/team.ics' } });
|
||||
const scopeFeeds = (latest!.policy?.scopes?.[0].policy.organisation as { calendars: { feeds: { name: string }[] } }).calendars.feeds;
|
||||
expect(scopeFeeds.map((f) => f.name)).toEqual(['Team']);
|
||||
expect(latest!.policy?.organisation?.calendars.feeds.map((f) => f.name)).toEqual(['Holidays']);
|
||||
});
|
||||
|
||||
it('says what Lite needs and what a refresh costs', async () => {
|
||||
render(<Harness />);
|
||||
const notes = await screen.findByTestId('org-feed-notes');
|
||||
expect(notes).toHaveTextContent('Access-Control-Allow-Origin');
|
||||
expect(notes).toHaveTextContent('policy.json is a public file');
|
||||
expect(notes).toHaveTextContent('1000 files or 50 MB an hour');
|
||||
expect(notes).toHaveTextContent('intranet');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
// Organisation > Calendars > Free/busy sharing: the group every user's
|
||||
// default calendar shares its free/busy with (calendars.freeBusyGroup).
|
||||
|
||||
const puts: Array<Record<string, unknown>> = [];
|
||||
let stored: Record<string, unknown> = {};
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'PUT') {
|
||||
const body = JSON.parse(String(init.body));
|
||||
puts.push(body);
|
||||
stored = body;
|
||||
return new Response('{"ok":true}');
|
||||
}
|
||||
return new Response(JSON.stringify(stored));
|
||||
}),
|
||||
withBasePath: (p: string) => p,
|
||||
getPathPrefix: () => '',
|
||||
}));
|
||||
|
||||
import { FreeBusySection } from '../sections/free-busy-section';
|
||||
import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft';
|
||||
|
||||
const POLICY = {
|
||||
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
|
||||
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
|
||||
organisation: {},
|
||||
scopes: [{ id: 'sales', label: 'Sales', match: { groups: ['sales@example.org'] }, policy: {} }],
|
||||
};
|
||||
|
||||
let latest: ReturnType<typeof useOrgPolicyDraft> | null = null;
|
||||
function Harness() {
|
||||
const draft = useOrgPolicyDraft('test');
|
||||
latest = draft;
|
||||
if (draft.loading) return null;
|
||||
return <FreeBusySection draft={draft} />;
|
||||
}
|
||||
|
||||
async function renderSection() {
|
||||
render(<Harness />);
|
||||
await waitFor(() => expect(screen.getByTestId('org-free-busy-notes')).toBeInTheDocument());
|
||||
return screen.getByLabelText('Share free/busy with (group address)') as HTMLInputElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
puts.length = 0;
|
||||
stored = JSON.parse(JSON.stringify(POLICY));
|
||||
latest = null;
|
||||
useOrgScopeStore.setState({ scopeId: EVERYONE });
|
||||
});
|
||||
|
||||
describe('Free/busy sharing section', () => {
|
||||
it('says CalDAV-only users are not covered and points at the directory setting', async () => {
|
||||
await renderSection();
|
||||
const notes = screen.getByTestId('org-free-busy-notes').textContent ?? '';
|
||||
expect(notes).toContain('never sign in to Bulwark');
|
||||
expect(notes).toContain('Allow Directory Queries');
|
||||
});
|
||||
|
||||
it('saves a group address and keeps a half-typed one out of the policy', async () => {
|
||||
const field = await renderSection();
|
||||
fireEvent.change(field, { target: { value: 'staff@' } });
|
||||
expect(screen.getByText('Enter a group address like staff@example.com.')).toBeInTheDocument();
|
||||
expect(latest!.orgValue('calendars', 'freeBusyGroup').value).toBe('');
|
||||
fireEvent.change(field, { target: { value: 'Staff@Example.org ' } });
|
||||
await act(async () => { await latest!.save(); });
|
||||
expect((puts[0].organisation as { calendars: { freeBusyGroup: string } }).calendars.freeBusyGroup).toBe('staff@example.org');
|
||||
});
|
||||
|
||||
it('writes a scope override only into that scope', async () => {
|
||||
const field = await renderSection();
|
||||
act(() => latest!.setScopeId('sales'));
|
||||
fireEvent.change(field, { target: { value: 'sales@example.org' } });
|
||||
expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ calendars: { freeBusyGroup: 'sales@example.org' } });
|
||||
expect(latest!.policy?.organisation?.calendars.freeBusyGroup).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
// Organisation > Calendars > Out-of-office: the vacationBlockCalendar lock,
|
||||
// with the value users get.
|
||||
|
||||
let stored: Record<string, unknown> = {};
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'PUT') {
|
||||
stored = JSON.parse(String(init.body));
|
||||
return new Response('{"ok":true}');
|
||||
}
|
||||
return new Response(JSON.stringify(stored));
|
||||
}),
|
||||
withBasePath: (p: string) => p,
|
||||
getPathPrefix: () => '',
|
||||
}));
|
||||
|
||||
import { OutOfOfficeCalendarSection } from '../sections/out-of-office-calendar-section';
|
||||
import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft';
|
||||
|
||||
const POLICY = {
|
||||
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
|
||||
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
|
||||
organisation: {},
|
||||
scopes: [{ id: 'sales', label: 'Sales', match: { groups: ['sales@example.org'] }, policy: {} }],
|
||||
};
|
||||
|
||||
let latest: ReturnType<typeof useOrgPolicyDraft> | null = null;
|
||||
function Harness() {
|
||||
const draft = useOrgPolicyDraft('test');
|
||||
latest = draft;
|
||||
if (draft.loading) return null;
|
||||
return <OutOfOfficeCalendarSection draft={draft} />;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stored = JSON.parse(JSON.stringify(POLICY));
|
||||
latest = null;
|
||||
useOrgScopeStore.setState({ scopeId: EVERYONE });
|
||||
});
|
||||
|
||||
describe('Out-of-office calendar section', () => {
|
||||
it('lets users choose by default and locks the block on or off with its value', async () => {
|
||||
render(<Harness />);
|
||||
await waitFor(() => expect(screen.getByTestId('org-ooo-notes')).toBeInTheDocument());
|
||||
const select = screen.getByLabelText('Out-of-office blocks the calendar') as HTMLSelectElement;
|
||||
expect(select.value).toBe('users');
|
||||
fireEvent.change(select, { target: { value: 'never' } });
|
||||
expect(latest!.restriction('vacationBlockCalendar').value).toEqual({ locked: true, value: false });
|
||||
fireEvent.change(select, { target: { value: 'always' } });
|
||||
await act(async () => { await latest!.save(); });
|
||||
expect((stored.restrictions as Record<string, unknown>).vacationBlockCalendar).toEqual({ locked: true, value: true });
|
||||
fireEvent.change(select, { target: { value: 'users' } });
|
||||
expect(latest!.restriction('vacationBlockCalendar').value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('writes a scope\'s lock only into that scope', async () => {
|
||||
render(<Harness />);
|
||||
await waitFor(() => expect(screen.getByTestId('org-ooo-notes')).toBeInTheDocument());
|
||||
act(() => latest!.setScopeId('sales'));
|
||||
fireEvent.change(screen.getByLabelText('Out-of-office blocks the calendar'), { target: { value: 'never' } });
|
||||
expect(latest!.policy?.scopes?.[0].policy.restrictions).toEqual({ vacationBlockCalendar: { locked: true, value: false } });
|
||||
expect(latest!.policy?.restrictions.vacationBlockCalendar).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import { useId } from 'react';
|
||||
import { Plus, Trash2 } from '@/components/icons';
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MIN_FEED_REFRESH_MINUTES, type CompanyFeed } from '@/lib/admin/organisation-policy';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import type { OrgPolicyDraft } from '../use-org-policy-draft';
|
||||
|
||||
const inputClass =
|
||||
'block w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ' +
|
||||
'placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-hidden focus-visible:border-ring';
|
||||
const labelClass = 'mb-1 block text-xs font-medium text-foreground';
|
||||
const DOCS_URL = 'https://bulwarkmail.org/docs/guides/organisation/company-calendars';
|
||||
/** Same cap as the sanitiser (organisation-policy.ts). */
|
||||
const MAX_FEEDS = 50;
|
||||
const MAX_REFRESH = 7 * 24 * 60;
|
||||
|
||||
/** The address as it will be saved, or null when it can't be one. */
|
||||
function feedUrlProblem(value: string): string | null {
|
||||
const v = value.trim().replace(/^webcals?:\/\//i, 'https://');
|
||||
if (!v) return 'Enter the address of the ICS feed.';
|
||||
try {
|
||||
const url = new URL(v);
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') return 'Use an https:// (or webcal://) address.';
|
||||
return null;
|
||||
} catch {
|
||||
return 'This isn\'t a web address.';
|
||||
}
|
||||
}
|
||||
|
||||
function FeedEditor({ feed, onChange, onRemove }: { feed: CompanyFeed; onChange: (feed: CompanyFeed) => void; onRemove: () => void }) {
|
||||
const id = useId();
|
||||
const set = (patch: Partial<CompanyFeed>) => onChange({ ...feed, ...patch });
|
||||
const urlProblem = feedUrlProblem(feed.url);
|
||||
return (
|
||||
<li className="space-y-3 rounded-md border border-border p-3" data-testid="org-feed-item">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_auto]">
|
||||
<div>
|
||||
<label htmlFor={`${id}-name`} className={labelClass}>Name</label>
|
||||
<input id={`${id}-name`} data-testid="org-feed-name" className={inputClass} value={feed.name} maxLength={100} onChange={(e) => set({ name: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-color`} className={labelClass}>Colour</label>
|
||||
<input id={`${id}-color`} type="color" className="h-9 w-16 cursor-pointer rounded-md border border-input bg-background" value={feed.color || '#2563eb'} onChange={(e) => set({ color: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-url`} className={labelClass}>ICS address</label>
|
||||
<input
|
||||
id={`${id}-url`}
|
||||
data-testid="org-feed-url"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
spellCheck={false}
|
||||
className={inputClass}
|
||||
placeholder="https://calendar.example.com/holidays.ics"
|
||||
value={feed.url}
|
||||
aria-invalid={!!urlProblem}
|
||||
aria-describedby={urlProblem ? `${id}-url-error` : undefined}
|
||||
onChange={(e) => set({ url: e.target.value })}
|
||||
/>
|
||||
{urlProblem && <p id={`${id}-url-error`} className="mt-1 text-xs text-red-600 dark:text-red-400">{urlProblem} A feed without one is not saved.</p>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<label htmlFor={`${id}-refresh`} className={labelClass}>Refresh every (minutes)</label>
|
||||
<input
|
||||
id={`${id}-refresh`}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={MIN_FEED_REFRESH_MINUTES}
|
||||
max={MAX_REFRESH}
|
||||
step={15}
|
||||
className={`${inputClass} w-32`}
|
||||
value={feed.refreshMinutes}
|
||||
onChange={(e) => {
|
||||
const n = Math.round(Number(e.target.value));
|
||||
if (Number.isFinite(n)) set({ refreshMinutes: Math.min(MAX_REFRESH, Math.max(MIN_FEED_REFRESH_MINUTES, n)) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="h-9 px-2 hover:text-red-600" aria-label={`Remove ${feed.name || 'calendar'}`}>
|
||||
<Trash2 className="me-1 h-4 w-4" /> Remove
|
||||
</Button>
|
||||
</div>
|
||||
{!feed.name.trim() && <p className="text-xs text-red-600 dark:text-red-400">A calendar without a name is not saved. Give it one.</p>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder from the enterprise-pack foundation, replaced by package B:
|
||||
* ICS feeds every user in scope sees read-only.
|
||||
* Organisation > Calendars > Company calendars
|
||||
* (policy.json `organisation.calendars.feeds`): ICS feeds every user in the
|
||||
* scope sees read-only under "Organisation". A scope's feeds add to
|
||||
* Everyone's by id.
|
||||
*/
|
||||
export function CompanyCalendarsSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
void draft;
|
||||
return <SettingsSection id="company-calendars" title="Company calendars" description="Not set up yet.">{null}</SettingsSection>;
|
||||
const feeds = draft.orgValue('calendars', 'feeds');
|
||||
const inScope = !!draft.scope;
|
||||
const own: CompanyFeed[] = inScope ? (feeds.own ? feeds.value : []) : feeds.value;
|
||||
const everyone = feeds.everyone;
|
||||
const setOwn = (next: CompanyFeed[]) => {
|
||||
if (inScope && next.length === 0) draft.clearOrgValue('calendars', 'feeds');
|
||||
else draft.setOrgValue('calendars', 'feeds', next);
|
||||
};
|
||||
const add = () => setOwn([...own, { id: generateUUID(), name: '', url: '', color: '#2563eb', refreshMinutes: MIN_FEED_REFRESH_MINUTES }]);
|
||||
const total = new Set([...everyone.map((f) => f.id), ...own.map((f) => f.id)]).size;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="company-calendars"
|
||||
title="Company calendars"
|
||||
description="ICS feeds such as holidays, office closures or company events. Users in the scope see them read-only under “Organisation” in Calendar and can hide them, not remove them."
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{inScope && (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-foreground">From Everyone</p>
|
||||
{everyone.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Everyone has no company calendars.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5" data-testid="org-feed-inherited">
|
||||
{everyone.map((feed) => (
|
||||
<li key={feed.id} className="flex items-center gap-2 rounded-md border border-dashed border-border px-3 py-2 text-sm">
|
||||
<span className="h-3 w-3 shrink-0 rounded-sm" style={{ backgroundColor: feed.color }} aria-hidden="true" />
|
||||
<span className="min-w-0 truncate">{feed.name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-4 mb-1 text-sm font-medium text-foreground">For this scope</p>
|
||||
<p className="mb-2 text-xs text-muted-foreground">Users in this scope get Everyone's calendars and these.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{own.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground" data-testid="org-feed-empty">
|
||||
{inScope ? 'Nothing added for this scope.' : 'No company calendars yet.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2" data-testid="org-feed-list">
|
||||
{own.map((feed, index) => (
|
||||
<FeedEditor
|
||||
key={feed.id}
|
||||
feed={feed}
|
||||
onChange={(next) => setOwn(own.map((f, i) => (i === index ? next : f)))}
|
||||
onRemove={() => setOwn(own.filter((_, i) => i !== index))}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="button" variant="outline" size="sm" onClick={add} disabled={total >= MAX_FEEDS} data-testid="org-feed-add">
|
||||
<Plus className="me-1 h-4 w-4" /> Add calendar
|
||||
</Button>
|
||||
{total >= MAX_FEEDS && <span className="text-xs text-muted-foreground">At most {MAX_FEEDS} company calendars.</span>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground" data-testid="org-feed-notes">
|
||||
<p>
|
||||
Bulwark's server fetches the feeds, at most once per refresh interval for everyone, and keeps the addresses to
|
||||
itself: users never see them. A feed may be on your intranet when its address names it: an intranet IP address
|
||||
(10.x, 172.16-31.x, 192.168.x), a .local or .internal name, or a name without dots. Loopback and link-local
|
||||
addresses are never reached. Feeds stay reachable with "No third-party traffic" on and are listed on
|
||||
Organisation > Security.
|
||||
</p>
|
||||
<p>
|
||||
Each refresh that changed the feed uploads it once to Stalwart for every user who has Calendar open, which counts
|
||||
against Stalwart's upload limit (1000 files or 50 MB an hour per user). Keep feeds small and the interval long.
|
||||
</p>
|
||||
<p>
|
||||
Bulwark Lite has no server: the browser fetches the feed itself, so the feed host must send CORS headers
|
||||
(Access-Control-Allow-Origin), and policy.json is a public file there, so don't put tokens in the addresses. A
|
||||
Lite build without third-party traffic only reaches the mail server: put the feed on the same host as Lite or the
|
||||
mail server.{' '}
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="font-medium text-primary underline-offset-2 hover:underline">
|
||||
How to set it up
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SettingItem, SettingsSection } from '@/components/settings/settings-section';
|
||||
import { isValidEmail } from '@/lib/validation';
|
||||
import { InheritNote } from '../org-page';
|
||||
import type { OrgPolicyDraft } from '../use-org-policy-draft';
|
||||
|
||||
const DOCS_URL = 'https://bulwarkmail.org/docs/guides/organisation/find-a-time';
|
||||
|
||||
/**
|
||||
* Placeholder from the enterprise-pack foundation, replaced by package B:
|
||||
* A group that gets free/busy access to every user's default calendar.
|
||||
* Organisation > Calendars > Free/busy sharing: a group that every user's
|
||||
* default calendar shares its free/busy with, applied by Bulwark at sign-in
|
||||
* (organisation.calendars.freeBusyGroup, '' is off).
|
||||
*/
|
||||
export function FreeBusySection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
void draft;
|
||||
return <SettingsSection id="free-busy" title="Free/busy sharing" description="Not set up yet.">{null}</SettingsSection>;
|
||||
const { value, own, everyone } = draft.orgValue('calendars', 'freeBusyGroup');
|
||||
const [text, setText] = useState(value);
|
||||
// The field follows the scope picked in the scope bar and a discard.
|
||||
useEffect(() => setText(value), [value]);
|
||||
const trimmed = text.trim().toLowerCase();
|
||||
const invalid = trimmed !== '' && !isValidEmail(trimmed);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="free-busy"
|
||||
title="Free/busy sharing"
|
||||
description="Suggest times and the attendee list only see busy times in calendars shared with the organiser. Name a group everyone is in, and Bulwark shares each user's busy times (not their events) with it."
|
||||
>
|
||||
<SettingItem
|
||||
label="Share free/busy with"
|
||||
description="A group address, for example staff@example.com. Empty turns it off."
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<input
|
||||
id="org-free-busy-group"
|
||||
type="email"
|
||||
inputMode="email"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-label="Share free/busy with (group address)"
|
||||
aria-invalid={invalid}
|
||||
aria-describedby={invalid ? 'org-free-busy-group-error' : undefined}
|
||||
placeholder="staff@example.com"
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
const next = e.target.value.trim().toLowerCase();
|
||||
if (next === '' || isValidEmail(next)) draft.setOrgValue('calendars', 'freeBusyGroup', next);
|
||||
}}
|
||||
className="h-9 w-full max-w-xs rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-hidden sm:w-72"
|
||||
/>
|
||||
{invalid && (
|
||||
<p id="org-free-busy-group-error" className="text-xs text-destructive">Enter a group address like staff@example.com.</p>
|
||||
)}
|
||||
<InheritNote
|
||||
draft={draft}
|
||||
own={own}
|
||||
everyone={everyone || 'off'}
|
||||
onReset={() => draft.clearOrgValue('calendars', 'freeBusyGroup')}
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<div className="space-y-1 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground" data-testid="org-free-busy-notes">
|
||||
<p>
|
||||
Bulwark adds the share to each user's default calendar when they sign in. It only adds: shares users made
|
||||
themselves stay, and a user who removes it gets it back at the next sign-in. It shows in the user's sharing list.
|
||||
</p>
|
||||
<p>
|
||||
Users who only use other apps (CalDAV, phones) and never sign in to Bulwark aren't covered. Users can also
|
||||
share their own free/busy under Settings > Calendar.
|
||||
</p>
|
||||
<p>
|
||||
Looking people up also needs directory queries on in Stalwart (Settings > Files & Sharing > Sharing >
|
||||
Allow Directory Queries).{' '}
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="font-medium text-primary underline-offset-2 hover:underline">
|
||||
How to set it up
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { SettingItem, SettingsSection } from '@/components/settings/settings-section';
|
||||
import { InheritNote } from '../org-page';
|
||||
import type { OrgPolicyDraft } from '../use-org-policy-draft';
|
||||
import type { SettingRestriction } from '@/lib/admin/types';
|
||||
|
||||
const KEY = 'vacationBlockCalendar';
|
||||
const DOCS_URL = 'https://bulwarkmail.org/docs/guides/organisation/out-of-office';
|
||||
|
||||
type Choice = 'users' | 'always' | 'never';
|
||||
|
||||
function choiceOf(r: SettingRestriction | undefined): Choice {
|
||||
if (r?.locked !== true) return 'users';
|
||||
return r.value === false ? 'never' : 'always';
|
||||
}
|
||||
|
||||
const LABELS: Record<Choice, string> = {
|
||||
users: 'Users choose (on until they turn it off)',
|
||||
always: 'Always block the calendar',
|
||||
never: 'Never block the calendar',
|
||||
};
|
||||
|
||||
/**
|
||||
* Placeholder from the enterprise-pack foundation, replaced by package B:
|
||||
* Whether an out-of-office reply blocks the calendar (restriction vacationBlockCalendar).
|
||||
* Organisation > Calendars > Out-of-office: whether an out-of-office reply
|
||||
* with dates puts a busy event in the user's calendar. Users choose by
|
||||
* default (on); a lock pins it on or off (restriction vacationBlockCalendar,
|
||||
* with its value).
|
||||
*/
|
||||
export function OutOfOfficeCalendarSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
void draft;
|
||||
return <SettingsSection id="out-of-office" title="Out-of-office" description="Not set up yet.">{null}</SettingsSection>;
|
||||
const { value, own, everyone } = draft.restriction(KEY);
|
||||
const choice = choiceOf(value);
|
||||
const set = (next: Choice) => draft.setRestriction(KEY, next === 'users' ? undefined : { locked: true, value: next === 'always' });
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="out-of-office"
|
||||
title="Out-of-office"
|
||||
description="When a user sets an out-of-office reply with a start and an end, Bulwark adds a busy event for that time to their default calendar, so colleagues don't book meetings with them."
|
||||
>
|
||||
<SettingItem
|
||||
label="Out-of-office blocks the calendar"
|
||||
description="Locked, users see the choice as set by the organisation."
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<select
|
||||
id="org-ooo-block"
|
||||
aria-label="Out-of-office blocks the calendar"
|
||||
value={choice}
|
||||
onChange={(e) => set(e.target.value as Choice)}
|
||||
className="h-9 w-full max-w-xs rounded-md border border-input bg-background px-2 text-sm text-foreground sm:w-72"
|
||||
>
|
||||
{(Object.keys(LABELS) as Choice[]).map((c) => <option key={c} value={c}>{LABELS[c]}</option>)}
|
||||
</select>
|
||||
<InheritNote
|
||||
draft={draft}
|
||||
own={own}
|
||||
everyone={LABELS[choiceOf(everyone)]}
|
||||
onReset={() => draft.setRestriction(KEY, undefined)}
|
||||
/>
|
||||
</div>
|
||||
</SettingItem>
|
||||
<div className="space-y-1 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground" data-testid="org-ooo-notes">
|
||||
<p>
|
||||
The event is busy, visible to everyone who sees the calendar, has no attendees and sends no invitations. If a user
|
||||
moves or changes it, Bulwark leaves it alone. Ending the reply early ends or removes the event.
|
||||
</p>
|
||||
<p>
|
||||
Bulwark follows replies set through JMAP (Bulwark, other JMAP apps). A reply another app sets up as a Sieve script of
|
||||
its own isn't visible to Bulwark, and the event is only updated when the user opens Bulwark.{' '}
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="font-medium text-primary underline-offset-2 hover:underline">
|
||||
More about out-of-office
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createElement } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { AnswerBars, useAnswerView } from '../answer-bars';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { CALENDAR_PRESETS } from '@/lib/jmap/share-rights';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
import type { AnswerTarget } from '@/lib/calendar-delegate';
|
||||
|
||||
const event = {
|
||||
id: 'alice:e1', uid: 'u1', title: 'Planning', start: '2026-11-04T14:00:00', duration: 'PT1H',
|
||||
calendarIds: { 'alice:b': true }, isShared: true, accountId: 'alice',
|
||||
organizerCalendarAddress: 'mailto:carol@example.org',
|
||||
participants: {
|
||||
carol: { '@type': 'Participant', name: 'Carol', email: 'carol@example.org', roles: { owner: true }, participationStatus: 'accepted' },
|
||||
alice: { '@type': 'Participant', name: 'Alice', email: 'alice@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
},
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
const calendar = {
|
||||
id: 'alice:b', originalId: 'b', name: 'Alice', accountId: 'alice', accountName: 'alice@example.org', isShared: true,
|
||||
myRights: CALENDAR_PRESETS.readWrite,
|
||||
} as unknown as Calendar;
|
||||
|
||||
function Harness({ onRespond }: { onRespond: (target: AnswerTarget, status: string) => void }) {
|
||||
const view = useAnswerView(event, calendar, ['bob@example.org'], false);
|
||||
return createElement(AnswerBars, { view, onRespond });
|
||||
}
|
||||
|
||||
describe('AnswerBars', () => {
|
||||
afterEach(() => {
|
||||
useAuthStore.setState({ client: null } as never);
|
||||
});
|
||||
|
||||
it('offers "Answer for <owner>" with the owner\'s name and answers with SENT-BY', async () => {
|
||||
const getAccountOwners = vi.fn(async () => ({ alice: { id: 'alice', type: 'individual' as const, name: 'alice@example.org', description: 'Alice Doe', email: 'alice@example.org' } }));
|
||||
useAuthStore.setState({ client: { getAccountOwners, hasAccountCapability: () => true } } as never);
|
||||
useCalendarStore.setState({ events: [event] });
|
||||
const onRespond = vi.fn();
|
||||
render(createElement(Harness, { onRespond }));
|
||||
|
||||
const bar = await screen.findByTestId('answer-for-owner');
|
||||
// The i18n mock renders keys; the group is named by the label.
|
||||
expect(bar).toHaveAttribute('aria-label', 'answer_for');
|
||||
expect(getAccountOwners).toHaveBeenCalledWith(['alice']);
|
||||
fireEvent.click(bar.querySelectorAll('button')[0]);
|
||||
expect(onRespond).toHaveBeenCalledWith(expect.objectContaining({
|
||||
kind: 'owner', eventId: 'alice:e1', participantId: 'alice', sentBy: 'mailto:bob@example.org',
|
||||
}), 'accepted');
|
||||
expect(screen.queryByTestId('answer-self')).toBeNull();
|
||||
});
|
||||
|
||||
it('says the user can\'t answer for the owner with read access only', async () => {
|
||||
useAuthStore.setState({ client: { getAccountOwners: async () => ({}), hasAccountCapability: () => true } } as never);
|
||||
const readOnly = { ...calendar, myRights: CALENDAR_PRESETS.read } as Calendar;
|
||||
function ReadOnly() {
|
||||
const view = useAnswerView(event, readOnly, ['bob@example.org'], false);
|
||||
return createElement(AnswerBars, { view, onRespond: vi.fn() });
|
||||
}
|
||||
render(createElement(ReadOnly));
|
||||
expect(await screen.findByTestId('answer-note-no_write')).toHaveTextContent('no_write');
|
||||
expect(screen.queryByTestId('answer-for-owner')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAnswerView with the user invited too', () => {
|
||||
afterEach(() => {
|
||||
useAuthStore.setState({ client: null } as never);
|
||||
});
|
||||
|
||||
it("answers for the user on the same occurrence of their own copy of a series", async () => {
|
||||
useAuthStore.setState({ client: { getAccountOwners: async () => ({}), hasAccountCapability: () => true } } as never);
|
||||
const both = {
|
||||
...event,
|
||||
participants: {
|
||||
...event.participants,
|
||||
bob: { '@type': 'Participant', name: 'Bob', email: 'bob@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
},
|
||||
} as unknown as CalendarEvent;
|
||||
const occurrence = (id: string, recurrenceId: string, shared: boolean) =>
|
||||
({ ...both, id, recurrenceId, isShared: shared, accountId: shared ? 'alice' : undefined } as unknown as CalendarEvent);
|
||||
const shared = occurrence('alice:occ-2', '2026-11-11T14:00:00', true);
|
||||
// The store holds a series as its expanded occurrences.
|
||||
useCalendarStore.setState({ events: [occurrence('own-1', '2026-11-04T14:00:00', false), occurrence('own-2', '2026-11-11T14:00:00', false), shared] });
|
||||
function Both() {
|
||||
const view = useAnswerView(shared, calendar, ['bob@example.org'], true);
|
||||
return createElement('div', { 'data-testid': 'targets' }, view.targets.map((t) => `${t.kind}:${t.eventId}`).join(','));
|
||||
}
|
||||
render(createElement(Both));
|
||||
expect(await screen.findByTestId('targets')).toHaveTextContent('owner:alice:occ-2,self:own-2');
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { useTaskStore } from '@/stores/task-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
|
||||
// The whole calendar page, with the shell around it stubbed out and the
|
||||
// stores holding a fixed set of calendars and events.
|
||||
@@ -842,3 +844,23 @@ describe('CalendarApp day view tasks', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('CalendarApp company calendars on a phone', () => {
|
||||
it('lists them in the toolbar\'s calendar list, where hiding one is the synced choice', async () => {
|
||||
media.mobile = true;
|
||||
media.desktop = false;
|
||||
const policy = usePolicyStore.getState().policy;
|
||||
usePolicyStore.setState({ policy: { ...policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, calendars: { feeds: [{ id: 'hol', name: 'Holidays', url: '', urlHeld: true, color: '#16a34a', refreshMinutes: 60 }], freeBusyGroup: '' } } } });
|
||||
useSettingsStore.setState({ hiddenCompanyCalendars: [] } as never);
|
||||
try {
|
||||
renderApp();
|
||||
// The toolbar's list (the sidebar heading has the same words).
|
||||
fireEvent.click(document.querySelector<HTMLElement>('button[aria-label="my_calendars"]')!);
|
||||
const list = (await screen.findByRole('heading', { name: 'my_calendars' })).parentElement!;
|
||||
fireEvent.click(within(list).getByRole('button', { name: 'Holidays' }));
|
||||
expect(useSettingsStore.getState().hiddenCompanyCalendars).toEqual(['hol']);
|
||||
} finally {
|
||||
usePolicyStore.setState({ policy });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { CalendarSidebarPanel } from '../calendar-sidebar-panel';
|
||||
import { createCompanyCalendar } from '@/lib/company-calendars';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { Calendar } from '@/lib/jmap/types';
|
||||
|
||||
// Company calendars sit under "Organisation", apart from the user's own,
|
||||
// and can only be shown or hidden: no menu to recolour, share or delete.
|
||||
|
||||
const own = {
|
||||
id: 'b', name: 'Mine', color: '#2563eb', sortOrder: 0, isSubscribed: true, isVisible: true, isDefault: true,
|
||||
includeInAvailability: 'all', timeZone: null, shareWith: null,
|
||||
myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayDelete: true, mayShare: true },
|
||||
} as unknown as Calendar;
|
||||
const company = createCompanyCalendar({ id: 'hol', name: 'Holidays', color: '#16a34a' });
|
||||
|
||||
describe('CalendarSidebarPanel company calendars', () => {
|
||||
it('lists them under Organisation, toggles them and offers no menu', () => {
|
||||
const onToggle = vi.fn();
|
||||
render(
|
||||
<CalendarSidebarPanel
|
||||
calendars={[own, company]}
|
||||
selectedCalendarIds={['b', company.id]}
|
||||
onToggleVisibility={onToggle}
|
||||
client={{ getAccountId: () => 'me' } as unknown as IJMAPClient}
|
||||
/>,
|
||||
);
|
||||
const section = screen.getByTestId('company-calendars-section');
|
||||
expect(section).toHaveTextContent('section');
|
||||
const item = within(section).getByText('Holidays');
|
||||
fireEvent.click(item);
|
||||
expect(onToggle).toHaveBeenCalledWith(company.id);
|
||||
expect(within(section).queryByRole('button', { name: 'calendar_options' })).toBeNull();
|
||||
// The user's own calendar is not in the section.
|
||||
expect(within(section).queryByText('Mine')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { render, fireEvent, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EventModal } from '../event-modal';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { getBrowserTimeZone } from '@/lib/timezone';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
// The event form's fields are a wall clock in the user's effective time
|
||||
// zone (the save path labels them with it). The attendees' free/busy must be
|
||||
// asked for the same instants, not for the wall clock read in the browser's
|
||||
// own zone.
|
||||
|
||||
const windows: Array<{ start: Date | null; end: Date | null }> = [];
|
||||
vi.mock('../participant-availability', () => ({
|
||||
ParticipantAvailability: (props: { start: Date | null; end: Date | null }) => {
|
||||
windows.push({ start: props.start, end: props.end });
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
const calendars = [{
|
||||
id: 'cal', name: 'Work', color: '#123456', isDefault: true,
|
||||
myRights: {
|
||||
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
|
||||
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
|
||||
},
|
||||
}] as unknown as Calendar[];
|
||||
|
||||
const meeting = {
|
||||
id: 'e1', uid: 'u1', '@type': 'Event', title: 'Planning', description: '', start: '2026-09-10T10:00:00',
|
||||
duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, calendarIds: { cal: true },
|
||||
organizerCalendarAddress: 'mailto:me@example.com',
|
||||
participants: {
|
||||
org: { '@type': 'Participant', name: 'Me', email: 'me@example.com', calendarAddress: 'mailto:me@example.com', roles: { owner: true }, participationStatus: 'accepted' },
|
||||
anna: { '@type': 'Participant', name: 'Anna', email: 'anna@example.com', calendarAddress: 'mailto:anna@example.com', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
},
|
||||
alerts: null, recurrenceRules: null,
|
||||
} as unknown as CalendarEvent;
|
||||
|
||||
describe('EventModal attendee availability window', () => {
|
||||
afterEach(() => {
|
||||
useSettingsStore.setState({ timeZone: 'auto' });
|
||||
windows.length = 0;
|
||||
});
|
||||
|
||||
it('asks for the event instants when the display zone is not the browser zone', () => {
|
||||
// A zone the browser running the test is not in.
|
||||
const zone = getBrowserTimeZone() === 'Asia/Tokyo' ? 'America/New_York' : 'Asia/Tokyo';
|
||||
useSettingsStore.setState({ timeZone: zone });
|
||||
render(
|
||||
<EventModal event={meeting} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} currentUserEmails={['me@example.com']} />,
|
||||
);
|
||||
fireEvent.click(screen.getByText('events.edit'));
|
||||
const last = windows[windows.length - 1];
|
||||
// 10:00 in Berlin on 10 September (summer time) is 08:00 UTC.
|
||||
expect(last.start?.toISOString()).toBe('2026-09-10T08:00:00.000Z');
|
||||
expect(last.end?.toISOString()).toBe('2026-09-10T09:00:00.000Z');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { createElement } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { FindATime, suggestTimes, visibleCalendarOwners } from '../find-a-time';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { BusyPeriod, Calendar, Principal, PrincipalListing } from '@/lib/jmap/types';
|
||||
|
||||
vi.mock('../free-busy-share-button', () => ({ FreeBusyShareButton: () => createElement('div', { 'data-testid': 'share-button' }) }));
|
||||
|
||||
const HOUR = 3600_000;
|
||||
// Monday 2 November 2026, 07:00 in Berlin.
|
||||
const NOW = new Date('2026-11-02T06:00:00Z');
|
||||
|
||||
const DIRECTORY: Principal[] = [
|
||||
{ id: 'me', type: 'individual', name: 'me', email: 'me@example.org' },
|
||||
{ id: 'bob', type: 'individual', name: 'bob', email: 'bob@example.org' },
|
||||
{ id: 'carol', type: 'individual', name: 'carol', email: 'carol@example.org' },
|
||||
];
|
||||
|
||||
function fakeClient(options: {
|
||||
listing?: PrincipalListing;
|
||||
periods?: Record<string, BusyPeriod[]>;
|
||||
errors?: Record<string, string>;
|
||||
}) {
|
||||
const calls: string[][] = [];
|
||||
const client = {
|
||||
getUsername: () => 'me@example.org',
|
||||
getAccountId: () => 'me',
|
||||
supportsAvailability: () => true,
|
||||
mayGetAvailability: async () => true,
|
||||
listPrincipals: async () => options.listing ?? { principals: DIRECTORY, status: 'ok' },
|
||||
getPrincipals: async () => DIRECTORY,
|
||||
getPrincipalsAvailability: async (ids: string[]) => {
|
||||
calls.push(ids);
|
||||
return Object.fromEntries(ids.map((id) => [id, options.errors?.[id] ? { error: options.errors[id] } : { periods: options.periods?.[id] ?? [] }]));
|
||||
},
|
||||
};
|
||||
return { client: client as unknown as IJMAPClient, calls };
|
||||
}
|
||||
|
||||
const busy = (utcStart: string, utcEnd: string): BusyPeriod => ({ utcStart, utcEnd, busyStatus: 'confirmed' });
|
||||
const input = (attendees: Array<{ name: string; email: string }>, visible: string[] = []) => ({
|
||||
attendees,
|
||||
durationMs: HOUR,
|
||||
now: NOW,
|
||||
timeZone: 'Europe/Berlin',
|
||||
workingDays: [1, 2, 3, 4, 5],
|
||||
dayStartHour: 9,
|
||||
dayEndHour: 17,
|
||||
visibleOwners: new Set(visible),
|
||||
});
|
||||
|
||||
describe('suggestTimes', () => {
|
||||
it('proposes the first slot free for everyone, the organiser included', async () => {
|
||||
const { client, calls } = fakeClient({
|
||||
periods: {
|
||||
me: [busy('2026-11-02T08:00:00Z', '2026-11-02T08:30:00Z')], // me 9:00-9:30
|
||||
bob: [busy('2026-11-02T08:30:00Z', '2026-11-02T10:00:00Z')], // bob 9:30-11
|
||||
carol: [busy('2026-11-02T10:00:00Z', '2026-11-02T11:00:00Z')], // carol 11-12
|
||||
},
|
||||
});
|
||||
const outcome = await suggestTimes(client, input([
|
||||
{ name: 'Bob', email: 'bob@example.org' },
|
||||
{ name: 'Carol', email: 'Carol@example.org' },
|
||||
]));
|
||||
expect(outcome.kind).toBe('ok');
|
||||
if (outcome.kind !== 'ok') return;
|
||||
expect(outcome.result.slots[0].start.toISOString()).toBe('2026-11-02T11:00:00.000Z'); // 12:00 Berlin
|
||||
expect(outcome.result.notChecked).toEqual([]);
|
||||
// One call per person over the whole window, in one batch.
|
||||
expect(calls).toEqual([['me', 'bob', 'carol']]);
|
||||
});
|
||||
|
||||
it('never counts an attendee without busy time as free unless their calendar is visible', async () => {
|
||||
const { client } = fakeClient({ periods: { bob: [busy('2026-11-02T08:00:00Z', '2026-11-02T09:00:00Z')] } });
|
||||
const hidden = await suggestTimes(client, input([{ name: 'Bob', email: 'bob@example.org' }, { name: 'Carol', email: 'carol@example.org' }]));
|
||||
expect(hidden.kind === 'ok' && hidden.result.notChecked).toEqual([{ email: 'carol@example.org', name: 'Carol', reason: 'unknown' }]);
|
||||
const visible = await suggestTimes(client, input([{ name: 'Carol', email: 'carol@example.org' }], ['carol@example.org']));
|
||||
expect(visible.kind === 'ok' && visible.result.notChecked).toEqual([]);
|
||||
});
|
||||
|
||||
it('names attendees outside the organisation and leaves them out', async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const outcome = await suggestTimes(client, input([{ name: '', email: 'x@outside.com' }]));
|
||||
expect(outcome.kind === 'ok' && outcome.result.notChecked).toEqual([{ email: 'x@outside.com', name: '', reason: 'external' }]);
|
||||
expect(calls).toEqual([['me']]);
|
||||
});
|
||||
|
||||
it('says directory queries are off when the server refused the listing, and still checks the people it knows', async () => {
|
||||
// Stalwart 1.0.1 with directory queries off: Principal/query refused,
|
||||
// the principals the user shares an account with still known.
|
||||
const { client, calls } = fakeClient({
|
||||
listing: { principals: [DIRECTORY[0], DIRECTORY[1]], status: 'restricted', directoryQueriesOff: true },
|
||||
periods: { bob: [busy('2026-11-02T08:00:00Z', '2026-11-02T09:00:00Z')] },
|
||||
});
|
||||
const outcome = await suggestTimes(client, input([{ name: 'Bob', email: 'bob@example.org' }, { name: 'Carol', email: 'carol@example.org' }]));
|
||||
expect(outcome.kind).toBe('ok');
|
||||
if (outcome.kind !== 'ok') return;
|
||||
expect(outcome.result.directoryQueriesOff).toBe(true);
|
||||
expect(outcome.result.notChecked).toEqual([{ email: 'carol@example.org', name: 'Carol', reason: 'not_found' }]);
|
||||
expect(outcome.result.slots[0].start.toISOString()).toBe('2026-11-02T09:00:00.000Z');
|
||||
expect(calls).toEqual([['me', 'bob']]);
|
||||
});
|
||||
|
||||
it('does not let the edited event hide its own time', async () => {
|
||||
// The meeting being moved sits at 9:00-10:00 Berlin in everyone's calendar.
|
||||
const { client } = fakeClient({ periods: { bob: [busy('2026-11-02T08:00:00Z', '2026-11-02T09:00:00Z')] } });
|
||||
const ownWindow = { start: new Date('2026-11-02T08:00:00Z'), end: new Date('2026-11-02T09:00:00Z') };
|
||||
const outcome = await suggestTimes(client, { ...input([{ name: 'Bob', email: 'bob@example.org' }]), ownWindow });
|
||||
expect(outcome.kind === 'ok' && outcome.result.slots[0].start.toISOString()).toBe('2026-11-02T08:00:00.000Z');
|
||||
});
|
||||
|
||||
it('says a lookup failed, and stops on forbidden', async () => {
|
||||
const failed = await suggestTimes(fakeClient({ errors: { bob: 'serverUnavailable' } }).client, input([{ name: 'Bob', email: 'bob@example.org' }]));
|
||||
expect(failed.kind === 'ok' && failed.result.notChecked).toEqual([{ email: 'bob@example.org', name: 'Bob', reason: 'error' }]);
|
||||
const refused = await suggestTimes(fakeClient({ errors: { me: 'forbidden', bob: 'forbidden' } }).client, input([{ name: 'Bob', email: 'bob@example.org' }]));
|
||||
expect(refused.kind).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('refuses a search that would go over the 100 calls a minute without asking', async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const many: Principal[] = Array.from({ length: 120 }, (_, i) => ({ id: `p${i}`, type: 'individual' as const, name: `p${i}`, email: `p${i}@example.org` }));
|
||||
const listing = { principals: [...DIRECTORY, ...many], status: 'ok' as const };
|
||||
Object.assign(client, { listPrincipals: async () => listing });
|
||||
const outcome = await suggestTimes(client, input(many.map((p) => ({ name: '', email: p.email! }))));
|
||||
expect(outcome.kind).toBe('rate_limited');
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts the calls of earlier searches against the minute', async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const people: Principal[] = Array.from({ length: 60 }, (_, i) => ({ id: `q${i}`, type: 'individual' as const, name: `q${i}`, email: `q${i}@example.org` }));
|
||||
Object.assign(client, { listPrincipals: async () => ({ principals: [...DIRECTORY, ...people], status: 'ok' }) });
|
||||
const first = await suggestTimes(client, input(people.map((p) => ({ name: '', email: p.email! }))));
|
||||
expect(first.kind).toBe('ok');
|
||||
// A different window, so nothing comes from the cache: 61 more would make 122.
|
||||
const second = await suggestTimes(client, { ...input(people.map((p) => ({ name: '', email: p.email! }))), now: new Date('2026-11-03T06:00:00Z') });
|
||||
expect(second.kind).toBe('rate_limited');
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('visibleCalendarOwners', () => {
|
||||
it('lists the owners of shared calendars the user may read', () => {
|
||||
const calendars = [
|
||||
{ isShared: true, accountName: 'Bob@example.org', myRights: { mayReadItems: true } },
|
||||
{ isShared: true, accountName: 'carol@example.org', myRights: { mayReadItems: false } },
|
||||
{ isShared: false, accountName: 'me@example.org', myRights: { mayReadItems: true } },
|
||||
] as unknown as Calendar[];
|
||||
expect([...visibleCalendarOwners(calendars)]).toEqual(['bob@example.org']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FindATime', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.setSystemTime(NOW);
|
||||
useSettingsStore.setState({ timeZone: 'Europe/Berlin', calendarWorkingDays: [1, 2, 3, 4, 5], calendarDayStartHour: 9, calendarDayEndHour: 17, timeFormat: '24h' });
|
||||
useCalendarStore.setState({ calendars: [] } as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
useAuthStore.setState({ client: null } as never);
|
||||
useSettingsStore.setState({ timeZone: 'auto' });
|
||||
});
|
||||
|
||||
it('lists slots, moves the event to the one picked, and closes on Escape with the focus back on the button', async () => {
|
||||
const { client } = fakeClient({ periods: { bob: [busy('2026-11-02T08:00:00Z', '2026-11-02T10:00:00Z')] } });
|
||||
useAuthStore.setState({ client } as never);
|
||||
const onPick = vi.fn();
|
||||
render(createElement(FindATime, {
|
||||
attendees: [{ name: 'Bob', email: 'bob@example.org' }],
|
||||
start: new Date('2026-11-05T09:00:00Z'),
|
||||
end: new Date('2026-11-05T09:30:00Z'),
|
||||
onPick,
|
||||
}));
|
||||
const button = screen.getByTestId('find-a-time-button');
|
||||
fireEvent.click(button);
|
||||
expect(button).toHaveAttribute('aria-expanded', 'true');
|
||||
const list = await screen.findByTestId('find-a-time-slots');
|
||||
const slots = list.querySelectorAll('button');
|
||||
expect(slots).toHaveLength(5);
|
||||
// The first half hour after Bob's 9-11 (Berlin).
|
||||
expect(slots[0].getAttribute('data-slot-start')).toBe('2026-11-02T10:00:00.000Z');
|
||||
fireEvent.click(slots[0]);
|
||||
expect(onPick).toHaveBeenCalledWith({ start: new Date('2026-11-02T10:00:00Z'), end: new Date('2026-11-02T10:30:00Z') });
|
||||
// Bob was checked; nothing is listed as not checked.
|
||||
expect(screen.queryByTestId('find-a-time-not-checked')).toBeNull();
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId('find-a-time-panel'), { key: 'Escape' });
|
||||
expect(screen.queryByTestId('find-a-time-panel')).toBeNull();
|
||||
expect(document.activeElement).toBe(button);
|
||||
});
|
||||
|
||||
it('says how many times it found, and Escape on the button closes the list without reaching the editor', async () => {
|
||||
const { client } = fakeClient({});
|
||||
useAuthStore.setState({ client } as never);
|
||||
const editorKeys = vi.fn();
|
||||
render(createElement('div', { onKeyDown: (e: { key: string }) => editorKeys(e.key) }, createElement(FindATime, {
|
||||
attendees: [{ name: 'Bob', email: 'bob@example.org' }],
|
||||
start: new Date('2026-11-05T09:00:00Z'),
|
||||
end: new Date('2026-11-05T09:30:00Z'),
|
||||
onPick: vi.fn(),
|
||||
})));
|
||||
const button = screen.getByTestId('find-a-time-button');
|
||||
fireEvent.click(button);
|
||||
await screen.findByTestId('find-a-time-slots');
|
||||
expect(screen.getByTestId('find-a-time-panel').querySelector('[role="status"]')).toHaveTextContent('found');
|
||||
button.focus();
|
||||
fireEvent.keyDown(button, { key: 'Escape' });
|
||||
expect(screen.queryByTestId('find-a-time-panel')).toBeNull();
|
||||
expect(editorKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('names the people it could not check and offers to share free/busy', async () => {
|
||||
const { client } = fakeClient({});
|
||||
useAuthStore.setState({ client } as never);
|
||||
render(createElement(FindATime, {
|
||||
attendees: [{ name: 'Carol', email: 'carol@example.org' }, { name: '', email: 'x@outside.com' }],
|
||||
start: new Date('2026-11-05T09:00:00Z'),
|
||||
end: new Date('2026-11-05T10:00:00Z'),
|
||||
onPick: vi.fn(),
|
||||
}));
|
||||
fireEvent.click(screen.getByTestId('find-a-time-button'));
|
||||
const note = await screen.findByTestId('find-a-time-not-checked');
|
||||
expect(note).toHaveTextContent('not_checked_item');
|
||||
expect(note).toHaveTextContent('unknown_note');
|
||||
expect(screen.getByTestId('share-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows nothing without attendees', () => {
|
||||
const { client } = fakeClient({});
|
||||
useAuthStore.setState({ client } as never);
|
||||
const { container } = render(createElement(FindATime, { attendees: [], start: NOW, end: new Date(NOW.getTime() + HOUR), onPick: vi.fn() }));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FreeBusyAutoShare } from '../free-busy-auto-share';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
|
||||
const apply = vi.fn(async () => 'shared' as const);
|
||||
vi.mock('@/lib/free-busy-share', () => ({ applyAdminFreeBusyShare: (...args: unknown[]) => apply(...(args as [])) }));
|
||||
|
||||
const client = { getUsername: () => 'alice@example.org', supportsCalendars: () => true };
|
||||
|
||||
function setPolicy(group: string, subjectEmail: string | null) {
|
||||
const current = usePolicyStore.getState().policy;
|
||||
usePolicyStore.setState({
|
||||
loaded: true,
|
||||
subject: subjectEmail ? { email: subjectEmail, groups: [] } : null,
|
||||
policy: { ...current, organisation: { ...DEFAULT_ORGANISATION_POLICY, calendars: { feeds: [], freeBusyGroup: group } } },
|
||||
});
|
||||
}
|
||||
|
||||
describe('FreeBusyAutoShare', () => {
|
||||
afterEach(() => {
|
||||
apply.mockClear();
|
||||
useAuthStore.setState({ client: null, isAuthenticated: false } as never);
|
||||
});
|
||||
|
||||
it('shares with the admin group once the policy is resolved for the signed-in account', () => {
|
||||
setPolicy('team@example.org', 'someone-else@example.org');
|
||||
useAuthStore.setState({ client, isAuthenticated: true } as never);
|
||||
render(<FreeBusyAutoShare />);
|
||||
// Still resolved for another account: nothing yet.
|
||||
expect(apply).not.toHaveBeenCalled();
|
||||
act(() => setPolicy('team@example.org', 'alice@example.org'));
|
||||
expect(apply).toHaveBeenCalledWith(client, 'team@example.org');
|
||||
});
|
||||
|
||||
it('does nothing while the admin set no group', () => {
|
||||
setPolicy('', 'alice@example.org');
|
||||
useAuthStore.setState({ client, isAuthenticated: true } as never);
|
||||
render(<FreeBusyAutoShare />);
|
||||
expect(apply).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -344,17 +344,38 @@ describe('ParticipantAvailability', () => {
|
||||
expect(screen.queryByText('unavailable_on_server')).toBeNull();
|
||||
});
|
||||
|
||||
it('says free/busy is not available when Stalwart 1.0 lists no directory because directory queries are off', async () => {
|
||||
// The own principal could not be read, so the listing's refusal is what tells.
|
||||
const { client, requests } = makeClient({
|
||||
ownPrincipal: http500,
|
||||
// Stalwart 1.0.1 refuses Principal/query with directory queries off but
|
||||
// still answers getAvailability for the principals the user shares an
|
||||
// account with (local-data/enterprise-pack-facts.md part 6 §1), so the
|
||||
// list stays and says who could not be looked up.
|
||||
it('keeps checking the known principals and says the rest cannot be looked up when Stalwart 1.0.1 has directory queries off', async () => {
|
||||
const { client } = makeClient({
|
||||
ownPrincipal: OWN_PRINCIPAL_1_0,
|
||||
directory: () => [DIRECTORY_QUERIES_OFF, UNRESOLVED_REFERENCE],
|
||||
known: ['Principal/get', { accountId: 'me', state: 'n', list: [DIRECTORY[0], DIRECTORY[1]], notFound: [] }, '0'],
|
||||
availability: (id, callId) => ['Principal/getAvailability', {
|
||||
list: id === 'anna' ? [{ utcStart: '2026-09-01T10:30:00Z', utcEnd: '2026-09-01T11:30:00Z', busyStatus: 'confirmed' }] : [],
|
||||
}, callId],
|
||||
});
|
||||
renderFor(client);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('busy')).toBeInTheDocument(), { timeout: 2000 });
|
||||
expect(screen.getByText('not_looked_up')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('participant-availability-directory-off')).toHaveTextContent('directory_off');
|
||||
expect(screen.queryByText('unavailable_on_server')).toBeNull();
|
||||
});
|
||||
|
||||
it('says free/busy is not available when a 1.0 build refuses the lookups as well with directory queries off', async () => {
|
||||
// The 1.0 test build (4fb4420b5) refuses every lookup with the same text.
|
||||
const { client } = makeClient({
|
||||
ownPrincipal: OWN_PRINCIPAL_1_0,
|
||||
directory: () => [DIRECTORY_QUERIES_OFF, UNRESOLVED_REFERENCE],
|
||||
known: ['Principal/get', { accountId: 'me', state: 'n', list: [DIRECTORY[0], DIRECTORY[1]], notFound: [] }, '0'],
|
||||
availability: () => DIRECTORY_QUERIES_OFF,
|
||||
});
|
||||
renderFor(client);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('unavailable_on_server')).toBeInTheDocument(), { timeout: 2000 });
|
||||
expect(screen.queryByTestId('participant-availability')).toBeNull();
|
||||
expect(methodsOf(requests)).not.toContain('Principal/getAvailability');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createElement } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
|
||||
const load = vi.fn(async () => []);
|
||||
vi.mock('@/lib/company-calendars', async (original) => ({
|
||||
...(await original<typeof import('@/lib/company-calendars')>()),
|
||||
loadFeedEvents: (...args: unknown[]) => load(...(args as [])),
|
||||
}));
|
||||
|
||||
import { useCompanyCalendars } from '../use-company-calendars';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// Two logins can share a JMAP account id (two servers): the cached feed
|
||||
// events are kept per login.
|
||||
|
||||
const FEED = { id: 'hol', name: 'Holidays', url: '', urlHeld: true, color: '#16a34a', refreshMinutes: 60 };
|
||||
|
||||
function Harness({ client }: { client: IJMAPClient }) {
|
||||
useCompanyCalendars(client);
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('useCompanyCalendars', () => {
|
||||
afterEach(() => load.mockClear());
|
||||
|
||||
it('keys the feed cache by the login, not the JMAP account id alone', async () => {
|
||||
const policy = usePolicyStore.getState().policy;
|
||||
usePolicyStore.setState({ policy: { ...policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, calendars: { feeds: [FEED], freeBusyGroup: '' } } } });
|
||||
const client = { getUsername: () => 'alice@example.org', getAccountId: () => 'b' } as unknown as IJMAPClient;
|
||||
render(createElement(Harness, { client }));
|
||||
await waitFor(() => expect(load).toHaveBeenCalledWith(client, 'alice@example.org|b', FEED));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { Calendar, CalendarEvent, CalendarParticipant, Principal } from "@/lib/jmap/types";
|
||||
import { accountOwnerOf, resolveAnswerTargets, type AccountOwner, type AnswerTarget, type AnswerView } from "@/lib/calendar-delegate";
|
||||
import { RsvpButtons } from "./rsvp-buttons";
|
||||
|
||||
type Status = CalendarParticipant["participationStatus"];
|
||||
|
||||
const owners = new WeakMap<IJMAPClient, Map<string, Promise<Principal | null>>>();
|
||||
|
||||
/** The principal behind a shared account, asked once per client. */
|
||||
function loadAccountOwner(client: IJMAPClient, accountId: string): Promise<Principal | null> {
|
||||
let cache = owners.get(client);
|
||||
if (!cache) {
|
||||
cache = new Map();
|
||||
owners.set(client, cache);
|
||||
}
|
||||
let owner = cache.get(accountId);
|
||||
if (!owner) {
|
||||
owner = client.getAccountOwners
|
||||
? client.getAccountOwners([accountId]).then((found) => found[accountId] ?? null).catch(() => null)
|
||||
: Promise.resolve(null);
|
||||
cache.set(accountId, owner);
|
||||
}
|
||||
return owner;
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner of a shared or group account (Principal/get, else the session's
|
||||
* account name), or null for none.
|
||||
*/
|
||||
export function useAccountOwner(client: IJMAPClient | null, accountId: string | null, accountName: string | undefined): AccountOwner | null {
|
||||
const [principal, setPrincipal] = useState<{ accountId: string; principal: Principal | null } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!client || !accountId) return;
|
||||
let cancelled = false;
|
||||
void loadAccountOwner(client, accountId).then((found) => {
|
||||
if (!cancelled) setPrincipal({ accountId, principal: found });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [client, accountId]);
|
||||
return useMemo(
|
||||
() => (accountId ? accountOwnerOf(principal?.accountId === accountId ? principal.principal : null, accountName) : null),
|
||||
[accountId, accountName, principal],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer bars of an event: the user's own, and "Answer for <owner>" for
|
||||
* an invitation in a calendar shared with them by its owner
|
||||
* (lib/calendar-delegate.ts).
|
||||
*/
|
||||
export function useAnswerView(
|
||||
event: CalendarEvent | null,
|
||||
calendar: Calendar | null | undefined,
|
||||
userEmails: string[],
|
||||
canRsvpSelf: boolean,
|
||||
): AnswerView {
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const events = useCalendarStore((s) => s.events);
|
||||
const sharedAccountId = calendar?.isShared ? calendar.accountId ?? null : null;
|
||||
const owner = useAccountOwner(client, sharedAccountId, calendar?.accountName);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!event) return { targets: [], note: null, answeredBy: null };
|
||||
// The same occurrence in the user's own calendar: the store holds a
|
||||
// series as its expanded occurrences.
|
||||
const ownCopy = event.uid
|
||||
? events.find((e) => e.uid === event.uid && !e.isShared && e.id !== event.id && (e.recurrenceId ?? null) === (event.recurrenceId ?? null)) ?? null
|
||||
: null;
|
||||
// Stalwart 1.0 lists principals:owner in every account; 0.16 never does.
|
||||
const server016 = !!client && !!sharedAccountId
|
||||
&& !client.hasAccountCapability("urn:ietf:params:jmap:principals:owner", sharedAccountId);
|
||||
return resolveAnswerTargets({ event, calendar, userEmails, canRsvpSelf, owner, ownCopy, server016 });
|
||||
}, [event, calendar, userEmails, canRsvpSelf, sharedAccountId, owner, events, client]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bars themselves: "Your response" and "Answer for <owner>", each with
|
||||
* accept, maybe and decline, and the line that says why there is no bar
|
||||
* for the owner, or who answered last for them.
|
||||
*/
|
||||
export function AnswerBars({ view, size, onRespond, className }: {
|
||||
view: AnswerView;
|
||||
size?: "xs";
|
||||
onRespond: (target: AnswerTarget, status: Status) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const t = useTranslations("calendar");
|
||||
const tDelegate = useTranslations("calendar_delegate");
|
||||
if (view.targets.length === 0 && !view.note && !view.answeredBy) return null;
|
||||
return (
|
||||
<div className={className} data-testid="answer-bars">
|
||||
<div className="space-y-3">
|
||||
{view.targets.map((target) => {
|
||||
const label = target.kind === "owner"
|
||||
? tDelegate("answer_for", { name: target.ownerName ?? "" })
|
||||
: t("participants.rsvp_label");
|
||||
return (
|
||||
<div
|
||||
key={`${target.kind}:${target.eventId}`}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
className="flex flex-wrap items-center justify-between gap-2"
|
||||
data-testid={target.kind === "owner" ? "answer-for-owner" : "answer-self"}
|
||||
>
|
||||
<span className={size === "xs" ? "text-xs font-medium text-muted-foreground" : "text-sm font-medium"}>{label}</span>
|
||||
<RsvpButtons status={target.status} onRespond={(status) => onRespond(target, status)} size={size} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{view.note && (
|
||||
<p className="mt-2 text-xs text-muted-foreground" role="note" data-testid={`answer-note-${view.note.kind}`}>
|
||||
{tDelegate(view.note.kind, { name: view.note.owner })}
|
||||
</p>
|
||||
)}
|
||||
{view.answeredBy && (
|
||||
<p className="mt-2 text-xs text-muted-foreground" data-testid="answered-by">
|
||||
{tDelegate("answered_by", { who: view.answeredBy })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -77,9 +77,13 @@ import { SchedulingDeniedError } from "@/lib/jmap/scheduling-error";
|
||||
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
|
||||
import { CalendarSidebarHeader } from "@/components/calendar/calendar-sidebar-header";
|
||||
import { getUserParticipantId, collectUserCalendarAddresses, userOrganizes, getParticipantList } from "@/lib/calendar-participants";
|
||||
import type { AnswerTarget } from "@/lib/calendar-delegate";
|
||||
import type { MoveEventHandler } from "@/hooks/use-time-grid-interactions";
|
||||
import type { RevealTime } from "@/hooks/use-display-hours";
|
||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
|
||||
import { feedIdOf, isCompanyCalendarId } from "@/lib/company-calendars";
|
||||
import { expandRecurringEvents } from "@/lib/recurrence-expansion";
|
||||
import { useCompanyCalendars } from "./use-company-calendars";
|
||||
import { sharedCalendarColorKey, autoAssignSharedCalendarColors } from "@/lib/shared-calendar-colors";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
|
||||
@@ -123,7 +127,7 @@ import { requestPrint, usePrintStore } from "@/stores/print-store";
|
||||
type PendingScopeAction =
|
||||
| { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; editor?: EventEditorState; sendScheduling?: boolean; fromEditor?: boolean }
|
||||
| { type: "delete"; event: CalendarEvent; sendScheduling?: boolean; fromEditor?: boolean }
|
||||
| { type: "rsvp"; event: CalendarEvent; participantId: string; status: CalendarParticipant["participationStatus"] };
|
||||
| { type: "rsvp"; event: CalendarEvent; participantId: string; status: CalendarParticipant["participationStatus"]; sentBy?: string };
|
||||
|
||||
function isRecurringEvent(event: CalendarEvent): boolean {
|
||||
return (event.recurrenceRules?.length ?? 0) > 0 || event.recurrenceId != null;
|
||||
@@ -1353,10 +1357,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
participantId: string,
|
||||
status: CalendarParticipant['participationStatus'],
|
||||
scope: 'occurrence' | 'series' = 'series',
|
||||
sentBy?: string,
|
||||
) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
if (await rsvpEvent(client, eventId, participantId, status, undefined, scope)) toast.success(t("notifications.rsvp_updated"));
|
||||
if (await rsvpEvent(client, eventId, participantId, status, undefined, scope, sentBy ? { sentBy } : undefined)) toast.success(t("notifications.rsvp_updated"));
|
||||
} catch (error) {
|
||||
toast.error(describeError(error, t("notifications.rsvp_error")));
|
||||
}
|
||||
@@ -1365,9 +1370,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
const handleScopeSelect = useCallback(async (scope: RecurrenceEditScope) => {
|
||||
if (!client || !pendingScopeAction) { toast.error(t("notifications.event_error")); return; }
|
||||
if (pendingScopeAction.type === "rsvp") {
|
||||
const { event, participantId, status } = pendingScopeAction;
|
||||
const { event, participantId, status, sentBy } = pendingScopeAction;
|
||||
setPendingScopeAction(null);
|
||||
await submitRsvp(event.id, participantId, status, scope === "this" ? "occurrence" : "series");
|
||||
await submitRsvp(event.id, participantId, status, scope === "this" ? "occurrence" : "series", sentBy);
|
||||
return;
|
||||
}
|
||||
const { type, event, sendScheduling, fromEditor } = pendingScopeAction;
|
||||
@@ -1473,16 +1478,16 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
});
|
||||
}, [client, updateEvent, resolveContactName, describeError, t]);
|
||||
|
||||
const handleRsvp = useCallback(async (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => {
|
||||
const handleRsvp = useCallback(async (eventId: string, participantId: string, status: CalendarParticipant['participationStatus'], sentBy?: string) => {
|
||||
if (!client) return;
|
||||
// An answer on one occurrence of a series asks whether it covers just
|
||||
// that occurrence or the whole series (#1086).
|
||||
const occurrence = events.find(e => e.id === eventId && e.recurrenceId != null);
|
||||
if (occurrence) {
|
||||
setPendingScopeAction({ type: "rsvp", event: occurrence, participantId, status });
|
||||
setPendingScopeAction({ type: "rsvp", event: occurrence, participantId, status, sentBy });
|
||||
return;
|
||||
}
|
||||
await submitRsvp(eventId, participantId, status);
|
||||
await submitRsvp(eventId, participantId, status, "series", sentBy);
|
||||
}, [client, events, submitRsvp]);
|
||||
|
||||
const handleDeleteFromDetail = useCallback(() => {
|
||||
@@ -1571,12 +1576,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
handleDeleteEvent(event.id, hasParticipants || undefined);
|
||||
}, [handleDeleteEvent]);
|
||||
|
||||
const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus']) => {
|
||||
const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus'], target?: AnswerTarget) => {
|
||||
if (!detailEvent || !client) return;
|
||||
const participantId = getUserParticipantId(detailEvent, currentUserEmails);
|
||||
// A target names the event and participant: the owner's in a calendar
|
||||
// shared with the user, or the user's own copy (lib/calendar-delegate.ts).
|
||||
const participantId = target?.participantId ?? getUserParticipantId(detailEvent, currentUserEmails);
|
||||
if (!participantId) return;
|
||||
closeDetail();
|
||||
await handleRsvp(detailEvent.id, participantId, status);
|
||||
await handleRsvp(target?.eventId ?? detailEvent.id, participantId, status, target?.sentBy);
|
||||
}, [detailEvent, client, currentUserEmails, closeDetail, handleRsvp]);
|
||||
|
||||
// `/` and the palette's "Search events" go to the search box.
|
||||
@@ -1688,10 +1695,42 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
}
|
||||
}, [calendars, sharedCalendarColors, setSharedCalendarColor]);
|
||||
|
||||
// Company calendars the admin set (lib/company-calendars.ts): read-only,
|
||||
// shown under "Organisation"; hiding one is a synced setting of its own.
|
||||
const company = useCompanyCalendars(client);
|
||||
const hiddenCompanyCalendars = useSettingsStore((s) => s.hiddenCompanyCalendars);
|
||||
const shownCalendarIds = useMemo(() => {
|
||||
if (company.calendars.length === 0) return selectedCalendarIds;
|
||||
const hidden = new Set(company.hiddenIds);
|
||||
return [...selectedCalendarIds, ...company.calendars.map((c) => c.id).filter((id) => !hidden.has(id))];
|
||||
}, [selectedCalendarIds, company.calendars, company.hiddenIds]);
|
||||
const toggleShownCalendar = useCallback((calendarId: string) => {
|
||||
if (!isCompanyCalendarId(calendarId)) {
|
||||
toggleCalendarVisibility(calendarId);
|
||||
return;
|
||||
}
|
||||
const feedId = feedIdOf(calendarId);
|
||||
const hidden = Array.isArray(hiddenCompanyCalendars) ? hiddenCompanyCalendars : [];
|
||||
updateSetting('hiddenCompanyCalendars', hidden.includes(feedId) ? hidden.filter((id) => id !== feedId) : [...hidden, feedId]);
|
||||
}, [toggleCalendarVisibility, hiddenCompanyCalendars, updateSetting]);
|
||||
const companyEvents = useMemo(() => {
|
||||
if (!dateRange || company.events.length === 0) return [];
|
||||
const hidden = new Set(company.hiddenIds);
|
||||
return expandRecurringEvents(company.events.filter((e) => !Object.keys(e.calendarIds).some((id) => hidden.has(id))), dateRange.start, dateRange.end);
|
||||
}, [company.events, company.hiddenIds, dateRange]);
|
||||
|
||||
// The phone toolbar's calendar list: the user's own and the company calendars.
|
||||
const toolbarCalendars = useMemo(
|
||||
() => (company.calendars.length > 0 ? [...displayCalendars, ...company.calendars] : displayCalendars),
|
||||
[displayCalendars, company.calendars],
|
||||
);
|
||||
|
||||
const allCalendars = useMemo(() => {
|
||||
if (!showBirthdayCalendar) return displayCalendars;
|
||||
return [...displayCalendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
|
||||
}, [displayCalendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
|
||||
const withBirthdays = showBirthdayCalendar
|
||||
? [...displayCalendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)]
|
||||
: displayCalendars;
|
||||
return company.calendars.length > 0 ? [...withBirthdays, ...company.calendars] : withBirthdays;
|
||||
}, [displayCalendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor, company.calendars]);
|
||||
|
||||
// What the user may change, by the rights of each event's calendar
|
||||
// (birthdays, subscriptions and read-only shares can't be changed).
|
||||
@@ -1715,11 +1754,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
const calIds = Object.keys(e.calendarIds);
|
||||
return calIds.some((id) => selectedCalendarIds.includes(id));
|
||||
});
|
||||
const withCompany = companyEvents.length > 0 ? [...filtered, ...companyEvents] : filtered;
|
||||
if (showBirthdayCalendar && selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) {
|
||||
return [...filtered, ...birthdayEvents];
|
||||
return [...withCompany, ...birthdayEvents];
|
||||
}
|
||||
return filtered;
|
||||
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents]);
|
||||
return withCompany;
|
||||
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents, companyEvents]);
|
||||
|
||||
// The tasks the task list shows; plugin buttons in its toolbar act on them.
|
||||
const listedTaskIds = useMemo(
|
||||
@@ -1996,7 +2036,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
rangeEnd={scrollWindow?.end ?? selectedDate}
|
||||
events={visibleEvents}
|
||||
calendars={allCalendars}
|
||||
shownCalendars={allCalendars.filter((c) => selectedCalendarIds.includes(c.id))}
|
||||
shownCalendars={allCalendars.filter((c) => shownCalendarIds.includes(c.id))}
|
||||
gridTasks={calendarTasks}
|
||||
tasks={taskStore.tasks}
|
||||
taskFilter={taskStore.filter}
|
||||
@@ -2027,9 +2067,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
onPrint={() => { void requestPrint({ kind: "calendar-view" }); }}
|
||||
isMobile={isMobile}
|
||||
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
|
||||
calendars={displayCalendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
onToggleVisibility={toggleCalendarVisibility}
|
||||
calendars={toolbarCalendars}
|
||||
selectedCalendarIds={shownCalendarIds}
|
||||
onToggleVisibility={toggleShownCalendar}
|
||||
enableCalendarTasks={enableCalendarTasks}
|
||||
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : isMobile ? undefined : toggleSidebarCollapsed}
|
||||
sidebarOpen={isNarrow ? narrowSidebarOpen : !sidebarCollapsed}
|
||||
@@ -2143,8 +2183,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
</div>
|
||||
<CalendarSidebarPanel
|
||||
calendars={allCalendars}
|
||||
selectedCalendarIds={selectedCalendarIds}
|
||||
onToggleVisibility={toggleCalendarVisibility}
|
||||
selectedCalendarIds={shownCalendarIds}
|
||||
onToggleVisibility={toggleShownCalendar}
|
||||
onColorChange={client ? (calendarId, color) => {
|
||||
if (calendarId === BIRTHDAY_CALENDAR_ID) {
|
||||
updateSetting('birthdayCalendarColor', color);
|
||||
|
||||
@@ -96,6 +96,7 @@ export function CalendarSidebarPanel({
|
||||
const paint = useCvdColor();
|
||||
const tSub = useTranslations("calendar.subscription");
|
||||
const tMgmt = useTranslations("calendar.management");
|
||||
const tCompany = useTranslations("company_calendars");
|
||||
const isSubscriptionCalendar = useCalendarStore((s) => s.isSubscriptionCalendar);
|
||||
const allSubs = useCalendarStore((s) => s.icalSubscriptions);
|
||||
const currentAccountId = client?.getAccountId();
|
||||
@@ -152,7 +153,9 @@ export function CalendarSidebarPanel({
|
||||
// view but hidden from the event calendar list here.
|
||||
const eventCalendars = useMemo(() => calendars.filter(c => !c.isTasksOnly), [calendars]);
|
||||
|
||||
const personalCalendars = useMemo(() => eventCalendars.filter(c => !c.isShared), [eventCalendars]);
|
||||
const personalCalendars = useMemo(() => eventCalendars.filter(c => !c.isShared && !c.isCompany), [eventCalendars]);
|
||||
// Company calendars the admin set: under "Organisation", read-only.
|
||||
const companyCalendars = useMemo(() => eventCalendars.filter(c => c.isCompany), [eventCalendars]);
|
||||
|
||||
// Names for the "shared with" hint. Principal ids belong to one server, so
|
||||
// only calendars of the account `client` talks to are resolved.
|
||||
@@ -280,7 +283,8 @@ export function CalendarSidebarPanel({
|
||||
const renderCalendarItem = (cal: Calendar) => {
|
||||
const isVisible = selectedCalendarIds.includes(cal.id);
|
||||
const color = paint(cal.color || "#3b82f6");
|
||||
const hasMenu = isSubscriptionCalendar(cal.id) ? !!client : true;
|
||||
// A company calendar can only be shown or hidden: no menu.
|
||||
const hasMenu = cal.isCompany ? false : isSubscriptionCalendar(cal.id) ? !!client : true;
|
||||
|
||||
return (
|
||||
<div key={cal.id} className="relative group/cal">
|
||||
@@ -607,6 +611,12 @@ export function CalendarSidebarPanel({
|
||||
onCreateCalendar,
|
||||
)}
|
||||
|
||||
{companyCalendars.length > 0 && (
|
||||
<div className="mt-2" data-testid="company-calendars-section">
|
||||
{renderSection('__organisation__', tCompany('section'), companyCalendars)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sharedAccountGroups.length > 0 && (
|
||||
<div className="mt-2">
|
||||
{renderSectionHeader('__others__', t('other_calendars'))}
|
||||
|
||||
@@ -19,7 +19,6 @@ import { buildRecurrenceSummary } from "./recurrence-editor";
|
||||
import { formatEventAlertsLabel } from "./alert-list-editor";
|
||||
import {
|
||||
getUserParticipantId,
|
||||
getUserStatus,
|
||||
getParticipantList,
|
||||
} from "@/lib/calendar-participants";
|
||||
import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability";
|
||||
@@ -29,7 +28,9 @@ import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@
|
||||
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { eventToView } from "@/lib/plugin-projection";
|
||||
import { ParticipantStatusBadge, RsvpButtons } from "./rsvp-buttons";
|
||||
import { ParticipantStatusBadge } from "./rsvp-buttons";
|
||||
import { AnswerBars, useAnswerView } from "./answer-bars";
|
||||
import type { AnswerTarget } from "@/lib/calendar-delegate";
|
||||
import { isEditableEventTarget } from "@/lib/keyboard";
|
||||
import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
|
||||
import { useCvdColor } from "@/hooks/use-cvd-color";
|
||||
@@ -47,7 +48,8 @@ interface EventDetailPopoverProps {
|
||||
onPrint?: () => void;
|
||||
onClose: () => void;
|
||||
onSaveNote: (note: string) => void;
|
||||
onRsvp?: (status: CalendarParticipant["participationStatus"]) => void;
|
||||
/** Without a target: the user's own answer on this event. */
|
||||
onRsvp?: (status: CalendarParticipant["participationStatus"], target?: AnswerTarget) => void;
|
||||
onMouseEnter?: () => void;
|
||||
onMouseLeave?: () => void;
|
||||
currentUserEmails?: string[];
|
||||
@@ -234,10 +236,7 @@ export function EventDetailPopover({
|
||||
[event, currentUserEmails]
|
||||
);
|
||||
|
||||
const userCurrentStatus = useMemo(
|
||||
() => getUserStatus(event, currentUserEmails),
|
||||
[event, currentUserEmails]
|
||||
);
|
||||
const answerView = useAnswerView(event, calendar, currentUserEmails, canRsvp && !!userParticipantId);
|
||||
|
||||
const formatTime = useCallback(
|
||||
(d: Date) => format(d, timeFormat === "12h" ? "h:mm a" : "HH:mm"),
|
||||
@@ -687,14 +686,14 @@ export function EventDetailPopover({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RSVP Bar (for attendees) */}
|
||||
{canRsvp && onRsvp && userParticipantId && (
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||
{t("participants.rsvp_label")}
|
||||
</p>
|
||||
<RsvpButtons status={userCurrentStatus} onRespond={onRsvp} size="xs" />
|
||||
</div>
|
||||
{/* RSVP bars: the user's own, and "Answer for <owner>" in a calendar shared with them. */}
|
||||
{onRsvp && (
|
||||
<AnswerBars
|
||||
view={answerView}
|
||||
size="xs"
|
||||
className="px-4 py-3 border-t border-border empty:hidden"
|
||||
onRespond={(target, status) => onRsvp(status, target)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { LinkifiedText } from "@/components/ui/linkified-text";
|
||||
import { X, Trash2, Users, CalendarDays, CopyPlus, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, AlertTriangle, MoreVertical } from "@/components/icons";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links";
|
||||
import { ParticipantStatusBadge, RsvpButtons } from "./rsvp-buttons";
|
||||
import { ParticipantStatusBadge } from "./rsvp-buttons";
|
||||
import { format, parseISO, addHours, isSameDay } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||
import { AlertListEditor, AlertSummary, formatEventAlertsLabel } from "./alert-list-editor";
|
||||
@@ -17,14 +17,16 @@ import { alertRowToOffset, describeAlert, getCalendarDefaultAlerts, newAlertRow,
|
||||
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
import { displayNow, getEffectiveTimeZone } from "@/lib/timezone";
|
||||
import { displayNow, fromDisplayDate, fromWallClock, getEffectiveTimeZone, parseLocalDateTime, toZonedDisplayDate } from "@/lib/timezone";
|
||||
import { createRecurrenceRule, sameRecurrenceRules } from "@/lib/recurrence-rule";
|
||||
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
|
||||
import { ParticipantAvailability } from "./participant-availability";
|
||||
import { FindATime } from "./find-a-time";
|
||||
import { AnswerBars, useAnswerView } from "./answer-bars";
|
||||
import type { AnswerTarget } from "@/lib/calendar-delegate";
|
||||
import {
|
||||
eventOrganizer,
|
||||
getUserParticipantId,
|
||||
getUserStatus,
|
||||
getParticipantList,
|
||||
getStatusCounts,
|
||||
participantFieldsToSave,
|
||||
@@ -78,7 +80,8 @@ interface EventModalProps {
|
||||
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, editor?: EventEditorState) => void | Promise<void>;
|
||||
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
||||
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
||||
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
|
||||
/** `sentBy`: answering for the calendar's owner (lib/calendar-delegate.ts). */
|
||||
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus'], sentBy?: string) => void;
|
||||
onClose: () => void;
|
||||
onPreviewChange?: (preview: PendingEventPreview | null) => void;
|
||||
/** Opens the event's action menu (export, copy, ...) under the ⋮ of the event view. */
|
||||
@@ -330,11 +333,6 @@ export function EventModal({
|
||||
return getUserParticipantId(event, currentUserEmails);
|
||||
}, [event, currentUserEmails]);
|
||||
|
||||
const userCurrentStatus = useMemo(() => {
|
||||
if (!event) return null;
|
||||
return getUserStatus(event, currentUserEmails);
|
||||
}, [event, currentUserEmails]);
|
||||
|
||||
const existingParticipants = useMemo(() => {
|
||||
if (!event) return [];
|
||||
return getParticipantList(event, { resolveName: resolveContactName });
|
||||
@@ -562,19 +560,43 @@ export function EventModal({
|
||||
// Leaving the form of an existing event goes back to its summary.
|
||||
const backToView = useCallback(() => whenDiscarded(() => { resetForm(); setMode("view"); }), [whenDiscarded, resetForm]);
|
||||
|
||||
// The event window the attendees' free/busy is checked against. Parsed in
|
||||
// local time like the save path; an all-day event spans its whole days.
|
||||
// The event window the attendees' free/busy is checked against. The
|
||||
// fields are a wall clock in the effective zone, as the save path labels
|
||||
// them, not in the browser's; an all-day event spans its whole days.
|
||||
const availabilityWindow = useMemo(() => {
|
||||
const zone = getEffectiveTimeZone();
|
||||
const instant = (value: string) => {
|
||||
const wall = parseLocalDateTime(value);
|
||||
if (!wall) return null;
|
||||
const date = fromWallClock(wall, zone);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
};
|
||||
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime || "00:00"}:00`;
|
||||
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime || "00:00"}:00`;
|
||||
const start = startDate ? new Date(startStr) : null;
|
||||
const end = endDate ? new Date(endStr) : null;
|
||||
return {
|
||||
start: start && !Number.isNaN(start.getTime()) ? start : null,
|
||||
end: end && !Number.isNaN(end.getTime()) ? end : null,
|
||||
start: startDate ? instant(startStr) : null,
|
||||
end: endDate ? instant(endStr) : null,
|
||||
};
|
||||
}, [startDate, startTime, endDate, endTime, allDay]);
|
||||
|
||||
// An existing event's own time, as saved: Suggest times doesn't count it
|
||||
// as busy (the attendees who have it in their calendar show it so).
|
||||
const savedWindow = useMemo(() => (event?.start && !event.showWithoutTime
|
||||
? { start: fromDisplayDate(getEventStartDate(event)), end: fromDisplayDate(getEventEndDate(event)) }
|
||||
: null), [event]);
|
||||
|
||||
// A suggested slot moves the event there; the fields are a wall clock in
|
||||
// the effective zone, so the instants are turned back into one.
|
||||
const handlePickSlot = useCallback((slot: { start: Date; end: Date }) => {
|
||||
const zone = getEffectiveTimeZone();
|
||||
const from = toZonedDisplayDate(slot.start, zone);
|
||||
const to = toZonedDisplayDate(slot.end, zone);
|
||||
setStartDate(formatDateInput(from));
|
||||
setStartTime(formatTimeInput(from));
|
||||
setEndDate(formatDateInput(to));
|
||||
setEndTime(formatTimeInput(to));
|
||||
}, []);
|
||||
|
||||
// Plugin transform: collect conflict warnings for the current event form.
|
||||
// Re-runs (debounced) whenever fields that affect scheduling change.
|
||||
const [pluginConflictWarnings, setPluginConflictWarnings] = useState<ConflictWarning[]>([]);
|
||||
@@ -917,11 +939,18 @@ export function EventModal({
|
||||
}
|
||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, calendars, recurrence, customRule, useDefaultAlerts, defaultAlerts, alertRows, preservedAlerts, attendees, initialAttendees, sendInvitations, currentUserEmails, userIsOrganizer, resolveContactName, event, onSave, isSaving, t]);
|
||||
|
||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||
if (!event || !userParticipantId || !onRsvp) return;
|
||||
onRsvp(event.id, userParticipantId, status);
|
||||
// The user's own answer, and the owner's in a calendar shared with them
|
||||
// (lib/calendar-delegate.ts).
|
||||
const eventCalendar = useMemo(
|
||||
() => (event ? calendars.find((c) => event.calendarIds?.[c.id]) ?? null : null),
|
||||
[event, calendars],
|
||||
);
|
||||
const answerView = useAnswerView(event ?? null, eventCalendar, currentUserEmails, canRsvp && !!userParticipantId);
|
||||
const handleAnswer = useCallback((target: AnswerTarget, status: CalendarParticipant['participationStatus']) => {
|
||||
if (!onRsvp) return;
|
||||
onRsvp(target.eventId, target.participantId, status, target.sentBy);
|
||||
onClose();
|
||||
}, [event, userParticipantId, onRsvp, onClose]);
|
||||
}, [onRsvp, onClose]);
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!event || !onDuplicate) return;
|
||||
@@ -1176,10 +1205,12 @@ export function EventModal({
|
||||
The viewer owes a reply but may also edit their own copy,
|
||||
so the rsvp-only branch above does not apply to this event.
|
||||
*/}
|
||||
{canRsvp && onRsvp && userParticipantId && (
|
||||
<div className="px-6 py-3 border-t border-border flex-shrink-0">
|
||||
<RsvpBar status={userCurrentStatus} onRespond={handleRsvp} t={t} />
|
||||
</div>
|
||||
{onRsvp && (
|
||||
<AnswerBars
|
||||
view={answerView}
|
||||
className="px-6 py-3 border-t border-border flex-shrink-0 empty:hidden"
|
||||
onRespond={handleAnswer}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Action Bar */}
|
||||
@@ -1323,6 +1354,15 @@ export function EventModal({
|
||||
start={availabilityWindow.start}
|
||||
end={availabilityWindow.end}
|
||||
/>
|
||||
{userIsOrganizer && !allDay && (
|
||||
<FindATime
|
||||
attendees={attendees}
|
||||
start={availabilityWindow.start}
|
||||
end={availabilityWindow.end}
|
||||
onPick={handlePickSlot}
|
||||
savedWindow={savedWindow}
|
||||
/>
|
||||
)}
|
||||
{isEdit && statusCounts && (existingParticipants.length > 0) && (
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{t("participants.status_summary", {
|
||||
@@ -1567,21 +1607,3 @@ export function EventModal({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The accept / tentative / decline row. Shared by the rsvp-only view and by the
|
||||
* read-only view that an *editable* received invite lands in - the latter is the
|
||||
* case that had no way to answer an invite at all (#937).
|
||||
*/
|
||||
function RsvpBar({ status, onRespond, t }: {
|
||||
status: CalendarParticipant['participationStatus'] | null;
|
||||
onRespond: (status: CalendarParticipant['participationStatus']) => void;
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
|
||||
<RsvpButtons status={status} onRespond={onRespond} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { CalendarClock, Loader2 } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { getEffectiveTimeZone } from "@/lib/timezone";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { BusyPeriod, Calendar } from "@/lib/jmap/types";
|
||||
import { availabilityCallsLeft, checkAvailabilityAccess, lookUpAvailability } from "@/lib/jmap/free-busy";
|
||||
import { findFreeSlots, searchWindow, type Slot } from "@/lib/find-a-time";
|
||||
import { loadAttendeeDirectory } from "./participant-availability";
|
||||
import { FreeBusyShareButton } from "./free-busy-share-button";
|
||||
|
||||
interface Attendee {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/** Why an attendee's calendar was left out of the search. */
|
||||
export type NotCheckedReason = "external" | "unknown" | "not_found" | "error";
|
||||
|
||||
export interface SuggestionResult {
|
||||
slots: Slot[];
|
||||
notChecked: Array<{ email: string; name: string; reason: NotCheckedReason }>;
|
||||
/** Some attendees couldn't be looked up because directory queries are off. */
|
||||
directoryQueriesOff: boolean;
|
||||
}
|
||||
|
||||
export type SuggestionOutcome =
|
||||
| { kind: "ok"; result: SuggestionResult }
|
||||
| { kind: "rate_limited" }
|
||||
| { kind: "unavailable" };
|
||||
|
||||
/**
|
||||
* The addresses of people whose calendar the user can see: an account shared
|
||||
* with them that holds a calendar they may read. For those, no busy time in
|
||||
* the answer means free. For everyone else it may only mean "not shared"
|
||||
* (Stalwart answers an empty list then too).
|
||||
*/
|
||||
export function visibleCalendarOwners(calendars: Pick<Calendar, "isShared" | "accountName" | "myRights">[]): Set<string> {
|
||||
const owners = new Set<string>();
|
||||
for (const c of calendars) {
|
||||
if (c.isShared && c.myRights?.mayReadItems && c.accountName?.includes("@")) owners.add(c.accountName.trim().toLowerCase());
|
||||
}
|
||||
return owners;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up everyone Bulwark can and search the free slots. One availability
|
||||
* call per attendee it can resolve plus one for the organiser, over the whole
|
||||
* search window; refused up front when that would go over what is left of
|
||||
* Stalwart 1.0's 100 calls a minute.
|
||||
*/
|
||||
export async function suggestTimes(
|
||||
client: IJMAPClient,
|
||||
input: {
|
||||
attendees: Attendee[];
|
||||
durationMs: number;
|
||||
now: Date;
|
||||
timeZone: string;
|
||||
workingDays: unknown;
|
||||
dayStartHour: unknown;
|
||||
dayEndHour: unknown;
|
||||
visibleOwners: Set<string>;
|
||||
/**
|
||||
* The event being edited, as it is saved: its own time shows up as busy
|
||||
* for everyone who has it in their calendar, and must not hide itself.
|
||||
*/
|
||||
ownWindow?: { start: Date; end: Date } | null;
|
||||
},
|
||||
): Promise<SuggestionOutcome> {
|
||||
const directory = await loadAttendeeDirectory(client);
|
||||
const own = client.getUsername()?.trim().toLowerCase() ?? "";
|
||||
const ownId = directory.byEmail.get(own)?.id ?? client.getAccountId();
|
||||
const people = input.attendees.filter((a) => a.email.trim().toLowerCase() !== own);
|
||||
const notChecked: SuggestionResult["notChecked"] = [];
|
||||
const ids = new Map<string, string>();
|
||||
let directoryQueriesOff = false;
|
||||
for (const person of people) {
|
||||
const email = person.email.trim().toLowerCase();
|
||||
const principal = directory.byEmail.get(email);
|
||||
if (principal) {
|
||||
ids.set(email, principal.id);
|
||||
} else if (directory.complete) {
|
||||
notChecked.push({ email, name: person.name, reason: "external" });
|
||||
} else {
|
||||
if (directory.directoryQueriesOff) directoryQueriesOff = true;
|
||||
notChecked.push({ email, name: person.name, reason: "not_found" });
|
||||
}
|
||||
}
|
||||
const principalIds = [ownId, ...ids.values()];
|
||||
if (availabilityCallsLeft(client) < principalIds.length) return { kind: "rate_limited" };
|
||||
|
||||
const window = searchWindow(input);
|
||||
const answers = await lookUpAvailability(client, principalIds, window.start, window.end);
|
||||
const busy: BusyPeriod[] = [];
|
||||
const own_ = answers[ownId];
|
||||
if (own_ && "periods" in own_) busy.push(...own_.periods);
|
||||
else if (own_ && "error" in own_ && own_.error === "forbidden") return { kind: "unavailable" };
|
||||
if (Object.values(answers).some((a) => "error" in a && a.error === "rateLimit")) return { kind: "rate_limited" };
|
||||
|
||||
for (const [email, id] of ids) {
|
||||
const person = people.find((p) => p.email.trim().toLowerCase() === email)!;
|
||||
const answer = answers[id];
|
||||
if (!answer || "error" in answer) {
|
||||
if (answer && "error" in answer && answer.error === "forbidden") return { kind: "unavailable" };
|
||||
notChecked.push({ email, name: person.name, reason: "error" });
|
||||
continue;
|
||||
}
|
||||
// Stalwart answers the same empty list for someone with nothing on and
|
||||
// for someone whose calendar the user may not see: only a calendar the
|
||||
// user can see makes "no busy time" mean free.
|
||||
if (answer.periods.length === 0 && !input.visibleOwners.has(email)) {
|
||||
notChecked.push({ email, name: person.name, reason: "unknown" });
|
||||
continue;
|
||||
}
|
||||
busy.push(...answer.periods);
|
||||
}
|
||||
|
||||
const saved = input.ownWindow;
|
||||
const others = saved
|
||||
? busy.filter((p) => !(Date.parse(p.utcStart) === saved.start.getTime() && Date.parse(p.utcEnd) === saved.end.getTime()))
|
||||
: busy;
|
||||
const slots = findFreeSlots({ ...input, busy: others });
|
||||
return { kind: "ok", result: { slots, notChecked, directoryQueriesOff } };
|
||||
}
|
||||
|
||||
function formatSlot(slot: Slot, locale: string, timeZone: string, hour12: boolean): { day: string; start: string; end: string } {
|
||||
const day = new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric", month: "short", timeZone }).format(slot.start);
|
||||
const time = new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", hour12, timeZone });
|
||||
return { day, start: time.format(slot.start), end: time.format(slot.end) };
|
||||
}
|
||||
|
||||
interface FindATimeProps {
|
||||
attendees: Attendee[];
|
||||
/** The event window as instants; its length is the meeting's. */
|
||||
start: Date | null;
|
||||
end: Date | null;
|
||||
/** Moves the event to the picked slot. */
|
||||
onPick: (slot: Slot) => void;
|
||||
/** An existing event's saved window, left out of everyone's busy time. */
|
||||
savedWindow?: { start: Date; end: Date } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Suggest times" next to the attendees' availability in the event editor:
|
||||
* up to five slots in the next ten working days that are free for everyone
|
||||
* whose calendar Bulwark could check, and who it couldn't check and why.
|
||||
* The list opens in place under the button (the editor is a full-screen
|
||||
* sheet on phones already); Escape closes it and gives the focus back.
|
||||
*/
|
||||
export function FindATime({ attendees, start, end, onPick, savedWindow }: FindATimeProps) {
|
||||
const t = useTranslations("find_time");
|
||||
const locale = useLocale();
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const calendars = useCalendarStore((s) => s.calendars);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const workingDays = useSettingsStore((s) => s.calendarWorkingDays);
|
||||
const dayStartHour = useSettingsStore((s) => s.calendarDayStartHour);
|
||||
const dayEndHour = useSettingsStore((s) => s.calendarDayEndHour);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [outcome, setOutcome] = useState<SuggestionOutcome | null>(null);
|
||||
const [picked, setPicked] = useState<string | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const panelId = useId();
|
||||
const seq = useRef(0);
|
||||
|
||||
const supported = !!client?.supportsAvailability?.() && !!client.getPrincipalsAvailability;
|
||||
const durationMs = start && end ? end.getTime() - start.getTime() : 0;
|
||||
const attendeesKey = attendees.map((a) => a.email.toLowerCase()).join(",");
|
||||
// A new set of people or a new length is a new question.
|
||||
useEffect(() => {
|
||||
seq.current++;
|
||||
setOutcome(null);
|
||||
setBusy(false);
|
||||
}, [attendeesKey, durationMs]);
|
||||
|
||||
const visibleOwners = useMemo(() => visibleCalendarOwners(calendars), [calendars]);
|
||||
|
||||
if (!supported || attendees.length === 0 || !(durationMs > 0)) return null;
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
};
|
||||
|
||||
const search = async () => {
|
||||
if (!client) return;
|
||||
const mine = ++seq.current;
|
||||
setOpen(true);
|
||||
setBusy(true);
|
||||
setPicked(null);
|
||||
const allowed = await checkAvailabilityAccess(client);
|
||||
const result: SuggestionOutcome = allowed
|
||||
? await suggestTimes(client, {
|
||||
attendees,
|
||||
durationMs,
|
||||
now: new Date(),
|
||||
timeZone: getEffectiveTimeZone(),
|
||||
workingDays,
|
||||
dayStartHour,
|
||||
dayEndHour,
|
||||
visibleOwners,
|
||||
ownWindow: savedWindow ?? null,
|
||||
}).catch((): SuggestionOutcome => ({ kind: "unavailable" }))
|
||||
: { kind: "unavailable" };
|
||||
if (mine !== seq.current) return;
|
||||
setOutcome(result);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const zone = getEffectiveTimeZone();
|
||||
const hour12 = timeFormat === "12h";
|
||||
const label = (slot: Slot) => {
|
||||
const f = formatSlot(slot, locale, zone, hour12);
|
||||
return t("slot", { day: f.day, start: f.start, end: f.end });
|
||||
};
|
||||
const result = outcome?.kind === "ok" ? outcome.result : null;
|
||||
const unknownBusy = result?.notChecked.some((n) => n.reason === "unknown") ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mt-2"
|
||||
onKeyDown={(e) => {
|
||||
// Escape closes the list, from the button too: the editor closes on
|
||||
// Escape as well, and this one is the list's.
|
||||
if (e.key === "Escape" && open) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
close();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => (open && !busy ? close() : void search())}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? panelId : undefined}
|
||||
data-testid="find-a-time-button"
|
||||
>
|
||||
<CalendarClock className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("suggest")}
|
||||
</Button>
|
||||
{open && (
|
||||
<div
|
||||
id={panelId}
|
||||
className="mt-2 rounded-md border border-border bg-muted/30 p-3 text-xs"
|
||||
data-testid="find-a-time-panel"
|
||||
>
|
||||
<div role="status" aria-live="polite" className="text-muted-foreground">
|
||||
{busy && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
||||
{t("searching")}
|
||||
</span>
|
||||
)}
|
||||
{!busy && outcome?.kind === "rate_limited" && t("rate_limited")}
|
||||
{!busy && outcome?.kind === "unavailable" && t("unavailable")}
|
||||
{!busy && result && result.slots.length === 0 && t("none")}
|
||||
{!busy && result && result.slots.length > 0 && !picked && <span className="sr-only">{t("found", { count: result.slots.length })}</span>}
|
||||
{!busy && picked && t("picked", { slot: picked })}
|
||||
</div>
|
||||
{!busy && result && result.slots.length > 0 && (
|
||||
<>
|
||||
<p className="mb-1.5 font-medium text-foreground">{t("slots_title")}</p>
|
||||
<ul className="space-y-1" aria-label={t("slots_title")} data-testid="find-a-time-slots">
|
||||
{result.slots.map((slot) => {
|
||||
const text = label(slot);
|
||||
return (
|
||||
<li key={slot.start.toISOString()}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-9 w-full items-center rounded-md px-2 py-1.5 text-start text-sm text-foreground hover:bg-accent focus-visible:bg-accent"
|
||||
onClick={() => {
|
||||
onPick(slot);
|
||||
setPicked(text);
|
||||
}}
|
||||
data-slot-start={slot.start.toISOString()}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{!busy && result && result.notChecked.length > 0 && (
|
||||
<div className="mt-2 text-muted-foreground" data-testid="find-a-time-not-checked">
|
||||
<p>{t("not_checked")}</p>
|
||||
<ul className="mt-0.5 list-disc ps-4">
|
||||
{result.notChecked.map((n) => (
|
||||
<li key={n.email} className="break-words">
|
||||
{t("not_checked_item", { who: n.name ? `${n.name} (${n.email})` : n.email, reason: t(`reason_${n.reason}`) })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{unknownBusy && <p className="mt-1">{t("unknown_note")}</p>}
|
||||
{result.directoryQueriesOff && <p className="mt-1">{t("directory_off")}</p>}
|
||||
</div>
|
||||
)}
|
||||
{!busy && result && unknownBusy && <FreeBusyShareButton className="mt-2" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { applyAdminFreeBusyShare } from '@/lib/free-busy-share';
|
||||
|
||||
/**
|
||||
* Applies the admin's free/busy group (organisation.calendars.freeBusyGroup)
|
||||
* to the signed-in account's default calendar, once per page load and
|
||||
* account: at sign-in, on a reload, and when another account becomes the
|
||||
* active one. Waits until the policy is resolved for that account, so a
|
||||
* group set in a domain or group scope applies to the right people. Renders
|
||||
* nothing.
|
||||
*/
|
||||
export function FreeBusyAutoShare() {
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const loaded = usePolicyStore((s) => s.loaded);
|
||||
const subjectEmail = usePolicyStore((s) => s.subject?.email ?? null);
|
||||
const group = usePolicyStore((s) => s.policy.organisation?.calendars?.freeBusyGroup ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !client || !loaded || !group) return;
|
||||
// The policy is resolved for the active account a moment after it
|
||||
// changes; wait for that rather than apply another account's group.
|
||||
if (!subjectEmail || subjectEmail !== client.getUsername()?.trim().toLowerCase()) return;
|
||||
if (!client.supportsCalendars?.()) return;
|
||||
void applyAdminFreeBusyShare(client, group);
|
||||
}, [client, isAuthenticated, loaded, subjectEmail, group]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Check, Loader2, Share2 } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { ensureFreeBusyShare, freeBusyShareGroups, freeBusyShareState } from "@/lib/free-busy-share";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* "Share my free/busy with <group>": shares the user's default calendar's
|
||||
* busy times (not its events) with the admin's free/busy group, else with a
|
||||
* group of the user's pick. Once shared it says so instead. Nothing shows
|
||||
* when there is no group to share with.
|
||||
*/
|
||||
export function FreeBusyShareButton({ className }: { className?: string }) {
|
||||
const t = useTranslations("free_busy_share");
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const adminGroup = usePolicyStore((s) => s.policy.organisation?.calendars?.freeBusyGroup ?? "");
|
||||
const groups = useMemo(() => freeBusyShareGroups(client, adminGroup), [client, adminGroup]);
|
||||
const [group, setGroup] = useState(groups[0] ?? "");
|
||||
const [shared, setShared] = useState<Record<string, boolean | null>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const selectId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!groups.includes(group)) setGroup(groups[0] ?? "");
|
||||
}, [groups, group]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !group || group in shared) return;
|
||||
let cancelled = false;
|
||||
void freeBusyShareState(client, group).catch(() => null).then((state) => {
|
||||
if (!cancelled) setShared((current) => ({ ...current, [group]: state }));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [client, group, shared]);
|
||||
|
||||
if (!client || groups.length === 0) return null;
|
||||
const state = shared[group];
|
||||
|
||||
const share = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await ensureFreeBusyShare(client, group);
|
||||
if (result === "shared" || result === "already") {
|
||||
setShared((current) => ({ ...current, [group]: true }));
|
||||
toast.success(t("done", { group }));
|
||||
} else {
|
||||
toast.error(t("error"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("error"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-2 text-xs", className)} data-testid="free-busy-share">
|
||||
{groups.length > 1 && (
|
||||
<>
|
||||
<label htmlFor={selectId} className="sr-only">{t("group_label")}</label>
|
||||
<select
|
||||
id={selectId}
|
||||
value={group}
|
||||
onChange={(e) => setGroup(e.target.value)}
|
||||
className="h-8 max-w-full rounded-md border border-border bg-background px-2 text-xs text-foreground"
|
||||
>
|
||||
{groups.map((g) => <option key={g} value={g}>{g}</option>)}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
{state === true ? (
|
||||
<span role="status" className="flex items-center gap-1 text-muted-foreground">
|
||||
<Check className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("already", { group })}
|
||||
</span>
|
||||
) : (
|
||||
<Button type="button" size="xs" variant="outline" onClick={() => void share()} disabled={saving || state === undefined} data-testid="free-busy-share-button">
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" /> : <Share2 className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||
{t("button", { group })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,11 +17,13 @@ interface Attendee {
|
||||
|
||||
/**
|
||||
* `unknown`: the address is not a user in a complete directory listing.
|
||||
* `not_looked_up`: the server refused to list its users (directory queries
|
||||
* off), so the address could not be matched to anyone.
|
||||
* `no_busy_times`: the server returned no busy time for the attendee's day.
|
||||
* It says the same when the user may not see that person's free/busy.
|
||||
* `error`: the lookup failed or was rate-limited, so nothing is known.
|
||||
*/
|
||||
export type AvailabilityStatus = "free" | "busy" | "tentative" | "no_busy_times" | "unknown" | "error" | "checking";
|
||||
export type AvailabilityStatus = "free" | "busy" | "tentative" | "no_busy_times" | "unknown" | "not_looked_up" | "error" | "checking";
|
||||
|
||||
interface ParticipantAvailabilityProps {
|
||||
attendees: Attendee[];
|
||||
@@ -50,10 +52,38 @@ export function availabilityFor(periods: BusyPeriod[], start: Date, end: Date):
|
||||
return status;
|
||||
}
|
||||
|
||||
/** Who the attendees are on this server, as far as the directory tells. */
|
||||
export interface AttendeeDirectory {
|
||||
byEmail: Map<string, Principal>;
|
||||
/** The listing names every user, so an address that isn't in it is not one. */
|
||||
complete: boolean;
|
||||
/** The server refused the listing because directory queries are off (Stalwart 1.0's default). */
|
||||
directoryQueriesOff: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The principal directory by address. A refused listing still holds the
|
||||
* principals the user shares an account with (Principal/get answers for
|
||||
* those). Stalwart 1.0.1 looks up their free/busy with directory queries
|
||||
* off, so they are checked; the 1.0 builds that refuse the lookups as well
|
||||
* say so with `forbidden` on the lookup.
|
||||
*/
|
||||
export async function loadAttendeeDirectory(client: IJMAPClient): Promise<AttendeeDirectory> {
|
||||
const directory = await loadPrincipalDirectory(client);
|
||||
const byEmail = new Map<string, Principal>();
|
||||
for (const p of directory.principals) {
|
||||
if (p.email) byEmail.set(p.email.toLowerCase(), p);
|
||||
}
|
||||
return {
|
||||
byEmail,
|
||||
complete: directory.status === "ok",
|
||||
directoryQueriesOff: directory.status === "restricted" && !!directory.directoryQueriesOff,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The status of every address for the window, or null when the server won't
|
||||
* answer free/busy for this user: Stalwart 1.0 with directory queries off,
|
||||
* or a lookup refused with `forbidden`.
|
||||
* answer free/busy for this user: a lookup refused with `forbidden`.
|
||||
*/
|
||||
async function lookUpStatuses(
|
||||
client: IJMAPClient,
|
||||
@@ -61,17 +91,7 @@ async function lookUpStatuses(
|
||||
start: Date,
|
||||
end: Date,
|
||||
): Promise<Record<string, AvailabilityStatus> | null> {
|
||||
const directory = await loadPrincipalDirectory(client);
|
||||
// With directory queries off, Stalwart 1.0 refuses availability lookups as
|
||||
// well. Any other refused listing is a role without the permission to list
|
||||
// principals, which is not the one for free/busy (0.16 and 1.0 check them
|
||||
// one by one): the principals Principal/get still returned are looked up,
|
||||
// and a lookup refused in turn says it.
|
||||
if (directory.status === "restricted" && directory.directoryQueriesOff) return null;
|
||||
const byEmail = new Map<string, Principal>();
|
||||
for (const p of directory.principals) {
|
||||
if (p.email) byEmail.set(p.email.toLowerCase(), p);
|
||||
}
|
||||
const { byEmail, complete, directoryQueriesOff } = await loadAttendeeDirectory(client);
|
||||
const principalIds = emails.flatMap((email) => byEmail.get(email)?.id ?? []);
|
||||
const answers = await lookUpAvailability(client, principalIds, start, end);
|
||||
const statuses: Record<string, AvailabilityStatus> = {};
|
||||
@@ -79,7 +99,7 @@ async function lookUpStatuses(
|
||||
const principal = byEmail.get(email);
|
||||
if (!principal) {
|
||||
// Only a complete listing shows that an address is not a user here.
|
||||
statuses[email] = directory.status === "ok" ? "unknown" : "error";
|
||||
statuses[email] = complete ? "unknown" : directoryQueriesOff ? "not_looked_up" : "error";
|
||||
continue;
|
||||
}
|
||||
const answer = answers[principal.id];
|
||||
@@ -104,6 +124,7 @@ async function lookUpStatuses(
|
||||
*/
|
||||
export function ParticipantAvailability({ attendees, start, end }: ParticipantAvailabilityProps) {
|
||||
const t = useTranslations("calendar.participants.availability");
|
||||
const tFind = useTranslations("find_time");
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const supported = !!client?.supportsAvailability?.() && !!client.getPrincipalsAvailability;
|
||||
const [statuses, setStatuses] = useState<Record<string, AvailabilityStatus>>({});
|
||||
@@ -172,30 +193,38 @@ export function ParticipantAvailability({ attendees, start, end }: ParticipantAv
|
||||
}
|
||||
if (allowed !== true) return null;
|
||||
|
||||
const notLookedUp = Object.values(statuses).includes("not_looked_up");
|
||||
return (
|
||||
<ul className="mt-1.5 space-y-0.5" aria-label={t("title")} data-testid="participant-availability">
|
||||
{attendees.map((a) => {
|
||||
const status = statuses[a.email.trim().toLowerCase()] ?? "checking";
|
||||
return (
|
||||
<li key={a.email} className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2 w-2 shrink-0 rounded-full",
|
||||
status === "free" && "bg-success",
|
||||
status === "busy" && "bg-destructive",
|
||||
status === "tentative" && "bg-warning",
|
||||
(status === "no_busy_times" || status === "unknown" || status === "error" || status === "checking") && "bg-muted-foreground/40",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 truncate text-foreground/90" title={a.email}>{a.name || a.email}</span>
|
||||
<span className="ms-auto flex shrink-0 items-center gap-1 text-muted-foreground">
|
||||
{status === "checking" && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{t(status)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<>
|
||||
<ul className="mt-1.5 space-y-0.5" aria-label={t("title")} data-testid="participant-availability">
|
||||
{attendees.map((a) => {
|
||||
const status = statuses[a.email.trim().toLowerCase()] ?? "checking";
|
||||
return (
|
||||
<li key={a.email} className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2 w-2 shrink-0 rounded-full",
|
||||
status === "free" && "bg-success",
|
||||
status === "busy" && "bg-destructive",
|
||||
status === "tentative" && "bg-warning",
|
||||
(status === "no_busy_times" || status === "unknown" || status === "not_looked_up" || status === "error" || status === "checking") && "bg-muted-foreground/40",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 truncate text-foreground/90" title={a.email}>{a.name || a.email}</span>
|
||||
<span className="ms-auto flex shrink-0 items-center gap-1 text-muted-foreground">
|
||||
{status === "checking" && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{status === "not_looked_up" ? tFind("not_looked_up") : t(status)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{notLookedUp && (
|
||||
<p className="mt-1 text-xs text-muted-foreground" data-testid="participant-availability-directory-off">
|
||||
{tFind("directory_off")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { Calendar, CalendarEvent } from "@/lib/jmap/types";
|
||||
import type { CompanyFeed } from "@/lib/admin/organisation-policy";
|
||||
import { companyCalendarId, createCompanyCalendar, loadFeedEvents, toCompanyEvents } from "@/lib/company-calendars";
|
||||
|
||||
// Feeds decide for themselves when they are due (refreshMinutes, at least
|
||||
// an hour); this only looks whether one is.
|
||||
const CHECK_EVERY_MS = 15 * 60 * 1000;
|
||||
|
||||
const NO_FEEDS: CompanyFeed[] = [];
|
||||
|
||||
/**
|
||||
* The company calendars of the active account's policy, and their events
|
||||
* (series not expanded). Feeds the policy no longer lists go away at once;
|
||||
* feeds that arrive with a late policy load then.
|
||||
*/
|
||||
export function useCompanyCalendars(client: IJMAPClient | null): {
|
||||
calendars: Calendar[];
|
||||
events: CalendarEvent[];
|
||||
/** Calendar ids of the feeds the user hid. */
|
||||
hiddenIds: string[];
|
||||
} {
|
||||
const feeds = usePolicyStore((s) => s.policy.organisation?.calendars?.feeds ?? NO_FEEDS);
|
||||
const hidden = useSettingsStore((s) => s.hiddenCompanyCalendars);
|
||||
const [parsed, setParsed] = useState<Record<string, CalendarEvent[]>>({});
|
||||
// Per login, not just per JMAP account id: two servers can reuse ids.
|
||||
const accountKey = client ? `${client.getUsername?.() ?? ""}|${client.getAccountId?.() ?? ""}` : "";
|
||||
const feedsKey = JSON.stringify(feeds.map((f) => [f.id, f.url, f.refreshMinutes]));
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || feeds.length === 0) return;
|
||||
let cancelled = false;
|
||||
const load = () => {
|
||||
for (const feed of feeds) {
|
||||
void loadFeedEvents(client, accountKey, feed).then((events) => {
|
||||
if (cancelled) return;
|
||||
setParsed((current) => ({ ...current, [feed.id]: toCompanyEvents(feed.id, events) }));
|
||||
});
|
||||
}
|
||||
};
|
||||
load();
|
||||
const timer = setInterval(load, CHECK_EVERY_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
// feedsKey stands in for the feeds array identity.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [client, accountKey, feedsKey]);
|
||||
|
||||
return useMemo(() => {
|
||||
const calendars = feeds.map(createCompanyCalendar);
|
||||
const events = feeds.flatMap((feed) => parsed[feed.id] ?? []);
|
||||
const hiddenSet = new Set(Array.isArray(hidden) ? hidden : []);
|
||||
const hiddenIds = feeds.filter((f) => hiddenSet.has(f.id)).map((f) => companyCalendarId(f.id));
|
||||
return { calendars, events, hiddenIds };
|
||||
}, [feeds, parsed, hidden]);
|
||||
}
|
||||
@@ -12,6 +12,10 @@ const mocks = vi.hoisted(() => {
|
||||
getCalendarsAccountId: vi.fn(() => 'calendar-account'),
|
||||
getCalendarEvent: vi.fn(),
|
||||
queryCalendarEvents: vi.fn(async (): Promise<unknown[]> => []),
|
||||
// Who owns a shared or group account (Principal/get).
|
||||
getAccountOwners: vi.fn(async (ids: string[]) => Object.fromEntries(ids.map((id) => [id, id === 'team'
|
||||
? { id, type: 'group', name: 'team@example.com', email: 'team@example.com' }
|
||||
: { id, type: 'individual', name: id + '@example.com', email: id + '@example.com' }]))),
|
||||
};
|
||||
|
||||
const sourceClientMock = {
|
||||
@@ -588,6 +592,132 @@ describe('CalendarInvitationBanner', () => {
|
||||
expect(await screen.findByText('Response sent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("answers on the user's own copy, not on a copy in a calendar shared with them (#634)", async () => {
|
||||
const participants = {
|
||||
user: { '@type': 'Participant', email: 'user@example.com', name: 'User', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
boss: { '@type': 'Participant', email: 'boss@example.com', name: 'Boss', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
};
|
||||
// The boss shared their calendar with the user, and both are invited:
|
||||
// the copy in the boss's calendar is in the store first.
|
||||
mocks.calendarState.events = [
|
||||
{ id: 'boss:event-9', uid: 'uid-9', start: '2026-03-30T09:00:00Z', participants, isShared: true, accountId: 'boss' },
|
||||
{ id: 'event-9', uid: 'uid-9', start: '2026-03-30T09:00:00Z', participants },
|
||||
];
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([
|
||||
{ uid: 'uid-9', title: 'Both invited', start: '2026-03-30T09:00:00Z', participants },
|
||||
]);
|
||||
mocks.calendarState.rsvpEvent.mockResolvedValue(true);
|
||||
|
||||
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-9' })} />);
|
||||
|
||||
fireEvent.click(await screen.findByText('Accept'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(mocks.clientMock, 'event-9', 'user', 'accepted', null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('an invitation in a shared or group mailbox (#634)', () => {
|
||||
const calendarsBefore = mocks.calendarState.calendars;
|
||||
const withAccountCalendar = (accountId: string) => {
|
||||
mocks.calendarState.calendars = [
|
||||
...calendarsBefore,
|
||||
{ id: accountId + ':b', name: accountId, color: '#16a34a', isDefault: true, isShared: true, accountId, accountName: accountId + '@example.com', myRights: { mayWriteAll: true } } as never,
|
||||
];
|
||||
};
|
||||
const invited = (address: string) => ({
|
||||
[address.split('@')[0]]: { '@type': 'Participant', email: address, name: address, roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
});
|
||||
|
||||
it("answers in the group's calendar as the group, without SENT-BY", async () => {
|
||||
withAccountCalendar('team');
|
||||
try {
|
||||
const participants = invited('team@example.com');
|
||||
mocks.calendarState.events = [{ id: 'team:event-10', uid: 'uid-10', start: '2026-03-30T09:00:00Z', participants, isShared: true, accountId: 'team' }];
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([{ uid: 'uid-10', title: 'Team meeting', start: '2026-03-30T09:00:00Z', participants }]);
|
||||
mocks.calendarState.rsvpEvent.mockResolvedValue(true);
|
||||
|
||||
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-10', sourceAccountId: 'team' } as Partial<Email>)} />);
|
||||
await waitFor(() => expect(mocks.clientMock.getAccountOwners).toHaveBeenCalledWith(['team']));
|
||||
fireEvent.click(await screen.findByText('Accept'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(mocks.clientMock, 'team:event-10', 'team', 'accepted', null);
|
||||
});
|
||||
expect(mocks.calendarState.importEvents).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
mocks.calendarState.calendars = calendarsBefore;
|
||||
}
|
||||
});
|
||||
|
||||
it("answers in the boss's calendar for the boss, naming the delegate with SENT-BY", async () => {
|
||||
withAccountCalendar('boss');
|
||||
try {
|
||||
const participants = invited('boss@example.com');
|
||||
mocks.calendarState.events = [{ id: 'boss:event-11', uid: 'uid-11', start: '2026-03-30T09:00:00Z', participants, isShared: true, accountId: 'boss' }];
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([{ uid: 'uid-11', title: 'Board', start: '2026-03-30T09:00:00Z', participants }]);
|
||||
mocks.calendarState.rsvpEvent.mockResolvedValue(true);
|
||||
|
||||
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-11', sourceAccountId: 'boss' } as Partial<Email>)} />);
|
||||
await waitFor(() => expect(mocks.clientMock.getAccountOwners).toHaveBeenCalledWith(['boss']));
|
||||
fireEvent.click(await screen.findByText('Accept'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(
|
||||
mocks.clientMock, 'boss:event-11', 'boss', 'accepted', null, undefined, { sentBy: 'mailto:user@example.com' },
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
mocks.calendarState.calendars = calendarsBefore;
|
||||
}
|
||||
});
|
||||
|
||||
it("finds the group's copy on the server when the calendar wasn't loaded, and answers there", async () => {
|
||||
withAccountCalendar('team');
|
||||
try {
|
||||
const participants = invited('team@example.com');
|
||||
mocks.calendarState.events = [];
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([{ uid: 'uid-13', title: 'Team call', start: '2026-03-30T09:00:00Z', participants }]);
|
||||
mocks.clientMock.queryCalendarEvents.mockImplementation(async (...args: unknown[]) => (args[3] === 'team'
|
||||
? [{ id: 'event-13', uid: 'uid-13', start: '2026-03-30T09:00:00Z', participants, calendarIds: { b: true } }]
|
||||
: []));
|
||||
mocks.calendarState.rsvpEvent.mockResolvedValue(true);
|
||||
|
||||
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-13', sourceAccountId: 'team' } as Partial<Email>)} />);
|
||||
fireEvent.click(await screen.findByText('Accept'));
|
||||
|
||||
await waitFor(() => expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(mocks.clientMock, 'team:event-13', 'team', 'accepted', null));
|
||||
expect(mocks.clientMock.queryCalendarEvents).toHaveBeenCalledWith({ uid: 'uid-13' }, undefined, undefined, 'team');
|
||||
expect(mocks.calendarState.importEvents).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
mocks.calendarState.calendars = calendarsBefore;
|
||||
}
|
||||
});
|
||||
|
||||
it("adds an invitation not yet in the group's calendar there, not to the user's own", async () => {
|
||||
withAccountCalendar('team');
|
||||
try {
|
||||
const participants = invited('team@example.com');
|
||||
mocks.calendarState.events = [];
|
||||
mocks.clientMock.parseCalendarEvents.mockResolvedValue([{ uid: 'uid-12', title: 'Team offsite', start: '2026-03-30T09:00:00Z', participants }]);
|
||||
mocks.calendarState.importEvents.mockImplementation(async () => {
|
||||
mocks.calendarState.events = [{ id: 'team:event-12', uid: 'uid-12', start: '2026-03-30T09:00:00Z', participants, isShared: true, accountId: 'team' }];
|
||||
return 1;
|
||||
});
|
||||
mocks.calendarState.rsvpEvent.mockResolvedValue(true);
|
||||
|
||||
render(<CalendarInvitationBanner email={makeEmail({ id: 'email-12', sourceAccountId: 'team' } as Partial<Email>)} />);
|
||||
// The Accept button shows once the group is known as the attendee.
|
||||
fireEvent.click(await screen.findByText('Accept'));
|
||||
|
||||
await waitFor(() => expect(mocks.calendarState.rsvpEvent).toHaveBeenCalledWith(mocks.clientMock, 'team:event-12', 'team', 'accepted', null));
|
||||
expect(mocks.calendarState.importEvents.mock.calls[0][2]).toBe('team:b');
|
||||
} finally {
|
||||
mocks.calendarState.calendars = calendarsBefore;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('says nothing was sent when a plugin stops the answer', async () => {
|
||||
mocks.calendarState.events = [
|
||||
{
|
||||
|
||||
@@ -47,6 +47,7 @@ import { getEventStartDate } from '@/lib/calendar-utils';
|
||||
import { sanitizeColor } from '@/components/calendar/event-card';
|
||||
import { RecipientPopover } from './recipient-popover';
|
||||
import { useCalendarErrorMessage } from '@/hooks/use-calendar-error-message';
|
||||
import { useAccountOwner } from '@/components/calendar/answer-bars';
|
||||
|
||||
interface InvitationChangeItem {
|
||||
label: string;
|
||||
@@ -427,6 +428,33 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
return Array.from(new Set(emails));
|
||||
}));
|
||||
const calendarInvitationParsingEnabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
||||
const storeCalendars = useCalendarStore((s) => s.calendars);
|
||||
// An invitation in a group mailbox or in a mailbox another person shared
|
||||
// (a delegate reading the boss's mail) is answered in that account's
|
||||
// calendar, as that account: as the group without SENT-BY, as the person
|
||||
// with SENT-BY naming the user (lib/calendar-delegate.ts). Only for an
|
||||
// account of the active login, whose calendars the store holds.
|
||||
const mailAccountId = email.sourceAccountId ?? sharedOwnerAccountId ?? null;
|
||||
const ownAccountIds = [client?.getAccountId?.(), client?.getCalendarsAccountId?.()];
|
||||
const sharedMailAccount = mailAccountId && parseClient === client && !ownAccountIds.includes(mailAccountId)
|
||||
? mailAccountId
|
||||
: null;
|
||||
const sharedAccountCalendars = sharedMailAccount ? storeCalendars.filter((c) => c.accountId === sharedMailAccount && !c.isTasksOnly) : [];
|
||||
const sharedCalendar = sharedAccountCalendars.find((c) => c.isDefault) ?? sharedAccountCalendars[0] ?? null;
|
||||
const mailOwner = useAccountOwner(
|
||||
client,
|
||||
sharedMailAccount,
|
||||
sharedCalendar?.accountName ?? (sharedMailAccount ? client?.getAccountInfo?.(sharedMailAccount)?.name : undefined),
|
||||
);
|
||||
const answerAs = sharedMailAccount && mailOwner ? mailOwner : null;
|
||||
// The addresses that pick "your" participant in the invitation.
|
||||
const actingEmails = answerAs ? answerAs.emails : identityEmails;
|
||||
// Answering for a person (not a group) names the user with SENT-BY.
|
||||
// Set below, once the function exists; the parse callback reads it late.
|
||||
const loadSharedCopyRef = useRef<(uid: string) => Promise<CalendarEvent | null>>(async () => null);
|
||||
const answerOptions = answerAs && !answerAs.isGroup && identityEmails[0]
|
||||
? { sentBy: `mailto:${identityEmails[0]}` }
|
||||
: undefined;
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, deleteEvent, events: storeEvents, setSelectedDate } = useCalendarStore(useShallow((state) => ({
|
||||
calendars: state.calendars,
|
||||
@@ -503,7 +531,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
// only needed for the "already in calendar" pill, must not block the banner.
|
||||
// Filter by UID server-side; the previous unfiltered query fetched up to
|
||||
// 1000 events plus multiple /get batches just to find one match.
|
||||
if (parsed.uid && supportsCalendar) {
|
||||
if (parsed.uid && supportsCalendar && sharedMailAccount) {
|
||||
// The copy in the shared or group account the message belongs to.
|
||||
void loadSharedCopyRef.current(parsed.uid).catch(() => { /* ignore lookup failure */ });
|
||||
} else if (parsed.uid && supportsCalendar) {
|
||||
const storeHasIt = useCalendarStore.getState().events.some((e) => e.uid === parsed.uid);
|
||||
if (!storeHasIt) {
|
||||
client.queryCalendarEvents({ uid: parsed.uid })
|
||||
@@ -522,7 +553,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
setParseError(err instanceof Error ? err.message : String(err));
|
||||
setState('error');
|
||||
}
|
||||
}, [calendarInvitationParsingEnabled, client, parseClient, blobAccountId, attachment, supportsCalendar]);
|
||||
}, [calendarInvitationParsingEnabled, client, parseClient, blobAccountId, attachment, supportsCalendar, sharedMailAccount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (attachment && calendarInvitationParsingEnabled) {
|
||||
@@ -572,16 +603,53 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
const allowsRsvp = method === 'request';
|
||||
const allowsImport = method === 'request' || method === 'publish' || method === 'add' || method === 'unknown';
|
||||
|
||||
/** Finds the invitation's copy in the shared or group account and adds it to the store. */
|
||||
async function loadSharedCopy(uid: string): Promise<CalendarEvent | null> {
|
||||
if (!client || !sharedMailAccount) return null;
|
||||
const known = useCalendarStore.getState().events.find((e) => e.uid === uid && e.accountId === sharedMailAccount);
|
||||
if (known) return known;
|
||||
const found = await client.queryCalendarEvents({ uid }, undefined, undefined, sharedMailAccount);
|
||||
// As the store holds a shared account's events: prefixed ids, store calendar ids.
|
||||
const accountCalendars = useCalendarStore.getState().calendars.filter((c) => c.accountId === sharedMailAccount);
|
||||
const mapped = found.map((e): CalendarEvent => ({
|
||||
...e,
|
||||
id: `${sharedMailAccount}:${e.id}`,
|
||||
originalId: e.id,
|
||||
originalCalendarIds: e.calendarIds,
|
||||
calendarIds: Object.fromEntries(Object.entries(e.calendarIds ?? {}).map(([id, on]) => [
|
||||
accountCalendars.find((c) => (c.originalId ?? c.id) === id)?.id ?? `${sharedMailAccount}:${id}`, on,
|
||||
])),
|
||||
accountId: sharedMailAccount,
|
||||
accountName: accountCalendars[0]?.accountName,
|
||||
isShared: true,
|
||||
}));
|
||||
if (mapped.length === 0) return null;
|
||||
useCalendarStore.setState((s) => {
|
||||
const ids = new Set(s.events.map((e) => e.id));
|
||||
const added = mapped.filter((e) => !ids.has(e.id));
|
||||
return added.length > 0 ? { events: [...s.events, ...added] } : s;
|
||||
});
|
||||
return mapped.find((e) => !e.recurrenceId) ?? mapped[0];
|
||||
}
|
||||
|
||||
loadSharedCopyRef.current = loadSharedCopy;
|
||||
|
||||
// The copy in the account the message belongs to. For the user's own
|
||||
// mailbox that is their own calendar: a calendar shared with them by
|
||||
// someone invited too holds that person's copy under the same uid, and
|
||||
// answering there would answer for them (or, on Stalwart 0.16, for
|
||||
// whichever address it picks; the duplicate attendees and wrong senders of
|
||||
// #634).
|
||||
const existingEvent = parsedEvent?.uid
|
||||
? storeEvents.find((e) => e.uid === parsedEvent.uid)
|
||||
? storeEvents.find((e) => e.uid === parsedEvent.uid && (sharedMailAccount ? e.accountId === sharedMailAccount : !e.isShared))
|
||||
: null;
|
||||
|
||||
const currentUserParticipant = existingEvent
|
||||
? findParticipantByEmails(existingEvent, identityEmails)
|
||||
? findParticipantByEmails(existingEvent, actingEmails)
|
||||
: null;
|
||||
|
||||
const fallbackParsedParticipant = !currentUserParticipant && parsedEvent
|
||||
? findParticipantByEmails(parsedEvent, identityEmails)
|
||||
? findParticipantByEmails(parsedEvent, actingEmails)
|
||||
: null;
|
||||
|
||||
const participantForUser = currentUserParticipant || fallbackParsedParticipant;
|
||||
@@ -635,6 +703,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
const cancellationOutdated = isCancellation && cancelRemoval?.kind === 'outdated';
|
||||
|
||||
const resolveExistingEventForRsvp = async () => {
|
||||
if (sharedMailAccount) return existingEvent;
|
||||
if (!client || !existingEvent) {
|
||||
return existingEvent;
|
||||
}
|
||||
@@ -657,7 +726,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
|
||||
const handleRsvp = async (status: 'accepted' | 'tentative' | 'declined') => {
|
||||
if (!client || !parsedEvent || isProcessing) return;
|
||||
const calId = selectedCalendarId || calendars.find((c) => c.isDefault)?.id || calendars[0]?.id;
|
||||
// An invitation of a shared or group mailbox goes into that account's calendar.
|
||||
const calId = sharedMailAccount
|
||||
? sharedCalendar?.id
|
||||
: selectedCalendarId || calendars.find((c) => c.isDefault)?.id || calendars[0]?.id;
|
||||
setActionNotice(null);
|
||||
setActionError(null);
|
||||
setIsProcessing(true);
|
||||
@@ -669,11 +741,13 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
// and Stalwart queues the iTIP REPLY itself. A manual client-side reply
|
||||
// here produced duplicate emails.
|
||||
try {
|
||||
// A copy in a shared or group account may not be loaded yet: look it up
|
||||
// there before adding the invitation again.
|
||||
const eventForRsvp = existingEvent
|
||||
? await resolveExistingEventForRsvp()
|
||||
: null;
|
||||
: sharedMailAccount && parsedEvent.uid ? await loadSharedCopy(parsedEvent.uid) : null;
|
||||
const existingEventParticipant = eventForRsvp
|
||||
? findParticipantByEmails(eventForRsvp, identityEmails)
|
||||
? findParticipantByEmails(eventForRsvp, actingEmails)
|
||||
: null;
|
||||
const canFallbackToParsedParticipant = Boolean(
|
||||
existingEvent
|
||||
@@ -682,7 +756,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
);
|
||||
if (eventForRsvp && existingEventParticipant) {
|
||||
// false: a plugin stopped it and said why; keep the old answer.
|
||||
if (!(await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp))) return;
|
||||
if (!(await (answerOptions ? rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp, undefined, answerOptions) : rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp)))) return;
|
||||
setRsvpStatus(status);
|
||||
setActionNotice(t('rsvp_sent'));
|
||||
setState('parsed');
|
||||
@@ -715,12 +789,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
if (imported === null) return;
|
||||
if (imported > 0) {
|
||||
const newEvent = useCalendarStore.getState().events.find(
|
||||
(e) => e.uid === parsedEvent.uid
|
||||
(e) => e.uid === parsedEvent.uid && (sharedMailAccount ? e.accountId === sharedMailAccount : !e.isShared)
|
||||
);
|
||||
const participant = myParticipant
|
||||
|| (newEvent ? findParticipantByEmails(newEvent, identityEmails) : null);
|
||||
|| (newEvent ? findParticipantByEmails(newEvent, actingEmails) : null);
|
||||
if (newEvent && participant) {
|
||||
if (!(await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp))) return;
|
||||
if (!(await (answerOptions ? rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp, undefined, answerOptions) : rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp)))) return;
|
||||
setRsvpStatus(status);
|
||||
setActionNotice(t('rsvp_sent'));
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const changed = vi.fn();
|
||||
const stops = vi.fn(async () => true);
|
||||
vi.mock('@/lib/vacation-calendar', async (original) => ({
|
||||
...(await original<typeof import('@/lib/vacation-calendar')>()),
|
||||
vacationBlockChanged: (...args: unknown[]) => changed(...args),
|
||||
ownFilterScriptStops: () => stops(),
|
||||
}));
|
||||
|
||||
import { VacationCalendarBlock, VacationFilterWarning } from '../vacation-calendar-block';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
|
||||
const client = {
|
||||
supportsCalendars: () => true,
|
||||
supportsSieve: () => true,
|
||||
accountSupports: () => true,
|
||||
getSieveScripts: vi.fn(async () => []),
|
||||
getAccountId: () => 'alice',
|
||||
getAllCalendars: vi.fn(async () => [{ id: 'b', isDefault: true, myRights: { mayWriteAll: true } }]),
|
||||
};
|
||||
|
||||
function lock(value: boolean | null) {
|
||||
const policy = usePolicyStore.getState().policy;
|
||||
usePolicyStore.setState({ policy: { ...policy, defaults: {}, restrictions: value === null ? {} : { vacationBlockCalendar: { locked: true, value } } } });
|
||||
}
|
||||
|
||||
describe('VacationCalendarBlock', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ client } as never);
|
||||
useSettingsStore.setState({ vacationBlockCalendar: true });
|
||||
useVacationStore.setState({ isEnabled: true, fromDate: '2026-12-01T08:00:00Z', toDate: '2026-12-05T17:00:00Z', subject: 'Away' });
|
||||
});
|
||||
afterEach(() => {
|
||||
lock(null);
|
||||
changed.mockClear();
|
||||
useAuthStore.setState({ client: null } as never);
|
||||
});
|
||||
|
||||
it('is on by default once both dates are set, and syncs when switched off', async () => {
|
||||
const { rerender } = render(<VacationCalendarBlock hasDates={false} />);
|
||||
expect(screen.queryByTestId('vacation-block-calendar')).toBeNull();
|
||||
rerender(<VacationCalendarBlock hasDates />);
|
||||
const box = await screen.findByTestId('vacation-block-calendar') as HTMLInputElement;
|
||||
expect(box.checked).toBe(true);
|
||||
fireEvent.click(box);
|
||||
expect(useSettingsStore.getState().vacationBlockCalendar).toBe(false);
|
||||
expect(changed).toHaveBeenCalledWith(client, undefined, { isEnabled: true, fromDate: '2026-12-01T08:00:00Z', toDate: '2026-12-05T17:00:00Z', subject: 'Away' });
|
||||
});
|
||||
|
||||
it('shows the organisation\'s lock with its value and can\'t be changed', async () => {
|
||||
lock(false);
|
||||
render(<VacationCalendarBlock hasDates />);
|
||||
const box = await screen.findByTestId('vacation-block-calendar') as HTMLInputElement;
|
||||
expect(box.checked).toBe(false);
|
||||
expect(screen.getByText('org_locked')).toBeInTheDocument();
|
||||
expect(box.closest('[inert]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows what the sync does for a lock without a value, and nothing when hidden', async () => {
|
||||
const policy = usePolicyStore.getState().policy;
|
||||
usePolicyStore.setState({ policy: { ...policy, defaults: { vacationBlockCalendar: false }, restrictions: { vacationBlockCalendar: { locked: true } } } });
|
||||
const view = render(<VacationCalendarBlock hasDates />);
|
||||
expect(((await screen.findByTestId('vacation-block-calendar')) as HTMLInputElement).checked).toBe(false);
|
||||
view.unmount();
|
||||
usePolicyStore.setState({ policy: { ...policy, defaults: {}, restrictions: { vacationBlockCalendar: { hidden: true } } } });
|
||||
const hidden = render(<VacationCalendarBlock hasDates />);
|
||||
await waitFor(() => expect(client.getAllCalendars).toHaveBeenCalled());
|
||||
expect(hidden.container.querySelector('[data-testid="vacation-block-calendar"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('says a managed account\'s calendar can\'t be blocked without write access', async () => {
|
||||
client.getAllCalendars.mockResolvedValueOnce([{ id: 'boss:b', isDefault: true, isShared: true, accountId: 'boss', myRights: { mayWriteAll: false, mayWriteOwn: false } }] as never);
|
||||
render(<VacationCalendarBlock hasDates accountId="boss" />);
|
||||
expect(await screen.findByTestId('vacation-block-no-access')).toHaveTextContent('no_access_managed');
|
||||
expect(screen.queryByTestId('vacation-block-calendar')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('VacationFilterWarning', () => {
|
||||
afterEach(() => useAuthStore.setState({ client: null } as never));
|
||||
|
||||
it('warns when the reply would switch off a filter script of the user\'s own', async () => {
|
||||
useAuthStore.setState({ client } as never);
|
||||
render(<VacationFilterWarning />);
|
||||
await waitFor(() => expect(screen.getByTestId('vacation-filter-warning')).toHaveTextContent('filter_warning'));
|
||||
stops.mockResolvedValueOnce(false);
|
||||
const other = render(<VacationFilterWarning accountId="boss" />);
|
||||
await waitFor(() => expect(stops).toHaveBeenCalledTimes(2));
|
||||
expect(other.container.querySelector('[data-testid="vacation-filter-warning"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { toast } from '@/stores/toast-store';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatDisplayHour } from '@/lib/calendar-display-range';
|
||||
import { FreeBusyShareSetting } from './free-busy-share-setting';
|
||||
|
||||
// Indexed by Date.getDay(), which is how calendarWorkingDays stores days.
|
||||
const DAY_KEYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] as const;
|
||||
@@ -277,6 +278,8 @@ export function CalendarSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<FreeBusyShareSetting />
|
||||
|
||||
{isFeatureEnabled('calendarTasksEnabled') && (
|
||||
<>
|
||||
<SettingItem
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { SettingItem } from './settings-section';
|
||||
import { FreeBusyShareButton } from '@/components/calendar/free-busy-share-button';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { freeBusyShareGroups } from '@/lib/free-busy-share';
|
||||
|
||||
/**
|
||||
* Settings > Calendar > Free/busy: share the default calendar's busy times
|
||||
* with the admin's free/busy group or one of the user's groups. Hidden when
|
||||
* there is no group to share with.
|
||||
*/
|
||||
export function FreeBusyShareSetting() {
|
||||
const t = useTranslations('free_busy_share');
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const adminGroup = usePolicyStore((s) => s.policy.organisation?.calendars?.freeBusyGroup ?? '');
|
||||
const groups = useMemo(() => freeBusyShareGroups(client, adminGroup), [client, adminGroup]);
|
||||
if (!client?.supportsCalendars() || groups.length === 0) return null;
|
||||
return (
|
||||
<SettingItem label={t('title')} description={t('description')}>
|
||||
<FreeBusyShareButton />
|
||||
</SettingItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AlertTriangle } from '@/components/icons';
|
||||
import { SettingItem } from './settings-section';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
import { useRestriction } from '@/hooks/use-restriction';
|
||||
import { blockCalendar, ownFilterScriptStops, resolveBlockSetting, vacationBlockChanged } from '@/lib/vacation-calendar';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
|
||||
/**
|
||||
* "Block my calendar" under the out-of-office dates (lib/vacation-calendar.ts):
|
||||
* on by default, lockable by the organisation (vacationBlockCalendar). Shown
|
||||
* once both dates are set. For a managed account the event goes into that
|
||||
* account's calendar, which the user needs write access to.
|
||||
*/
|
||||
export function VacationCalendarBlock({ hasDates, accountId }: { hasDates: boolean; accountId?: string }) {
|
||||
const t = useTranslations('vacation_calendar');
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const setting = useSettingsStore((s) => s.vacationBlockCalendar);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const restriction = useRestriction('vacationBlockCalendar');
|
||||
const policyDefault = usePolicyStore((s) => s.policy.defaults?.vacationBlockCalendar);
|
||||
// The same resolution as the sync (lib/vacation-calendar.ts).
|
||||
const { value: checked, locked, hidden } = resolveBlockSetting(restriction, policyDefault, setting);
|
||||
const [access, setAccess] = useState<{ key: string; ok: boolean } | null>(null);
|
||||
const id = useId();
|
||||
const key = accountId ?? '';
|
||||
// Stand-in clients (tests, the demo) may not say.
|
||||
const supported = typeof client?.supportsCalendars === 'function' && client.supportsCalendars();
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !supported || !hasDates) return;
|
||||
let cancelled = false;
|
||||
void client.getAllCalendars()
|
||||
.then((calendars) => !!blockCalendar(calendars, accountId ?? client.getAccountId(), client.getAccountId()))
|
||||
// Not knowing is no reason to hide the option.
|
||||
.catch(() => true)
|
||||
.then((ok) => { if (!cancelled) setAccess({ key, ok }); });
|
||||
return () => { cancelled = true; };
|
||||
}, [client, supported, hasDates, accountId, key]);
|
||||
|
||||
if (!client || !supported || !hasDates || hidden) return null;
|
||||
if (access?.key === key && !access.ok) {
|
||||
return (
|
||||
<p className="pb-2 text-xs text-muted-foreground" data-testid="vacation-block-no-access">
|
||||
{t(accountId ? 'no_access_managed' : 'no_calendar')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const onChange = (next: boolean) => {
|
||||
updateSetting('vacationBlockCalendar', next);
|
||||
const { isEnabled, fromDate, toDate, subject } = useVacationStore.getState();
|
||||
vacationBlockChanged(client, accountId, { isEnabled, fromDate, toDate, subject });
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingItem label={t('label')} description={t('description')} locked={locked}>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
aria-label={t('label')}
|
||||
className="h-5 w-5 rounded border-input accent-primary"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
data-testid="vacation-block-calendar"
|
||||
/>
|
||||
</SettingItem>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one line under the reply switch when turning it on would switch off a
|
||||
* filter script of the user's own (Stalwart runs one script at a time).
|
||||
*/
|
||||
export function VacationFilterWarning({ accountId }: { accountId?: string }) {
|
||||
const t = useTranslations('vacation_calendar');
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const [stops, setStops] = useState<{ key: string; value: boolean } | null>(null);
|
||||
const key = accountId ?? '';
|
||||
const sieve = !!client && typeof client.getSieveScripts === 'function'
|
||||
&& (accountId ? client.accountSupports(accountId, 'urn:ietf:params:jmap:sieve') : client.supportsSieve());
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !sieve) return;
|
||||
let cancelled = false;
|
||||
void ownFilterScriptStops(client, accountId).catch(() => false).then((value) => {
|
||||
if (!cancelled) setStops({ key, value });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [client, sieve, accountId, key]);
|
||||
|
||||
if (!stops || stops.key !== key || !stops.value) return null;
|
||||
return (
|
||||
<p className="flex items-start gap-1.5 -mt-1 pb-2 text-xs text-amber-600 dark:text-amber-400" data-testid="vacation-filter-warning">
|
||||
<AlertTriangle className="mt-px h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" />
|
||||
<span>{t('filter_warning')}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { isValidEmail } from '@/lib/validation';
|
||||
import { Loader2, AlertTriangle, Eye, EyeOff, Check } from '@/components/icons';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { VacationCalendarBlock, VacationFilterWarning } from './vacation-calendar-block';
|
||||
|
||||
const STALWART_VACATION_LIMITS = { subject: 511, body: 2047 };
|
||||
|
||||
@@ -455,6 +456,7 @@ export function VacationSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
<FieldMessage id="vacation-to-error" text={toError} />
|
||||
<VacationCalendarBlock hasDates={!!fromInput && !!toInput} accountId={managedAccountId ?? undefined} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="reply" title={t('auto_reply.title')} description={t('status.description')}>
|
||||
@@ -466,6 +468,7 @@ export function VacationSettings() {
|
||||
inactiveLabel={t('status.inactive')}
|
||||
/>
|
||||
</SettingItem>
|
||||
{!localEnabled && <VacationFilterWarning accountId={managedAccountId ?? undefined} />}
|
||||
{canNarrow && (
|
||||
<SettingItem
|
||||
label={t('audience.label')}
|
||||
|
||||
@@ -0,0 +1,720 @@
|
||||
import { test, expect, type Browser, type Page } from '@playwright/test';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { networkInterfaces } from 'node:os';
|
||||
import {
|
||||
ENT, ENT_SERVER, adminContext, getPolicy, jmapAs, putPolicy, rawMessage, requireEntEnv, shot, userPage, withPolicy,
|
||||
} from './helpers/ent';
|
||||
import { calendarItem, expandSharedFolders, folderRow, openCalendar } from './helpers/app';
|
||||
|
||||
/**
|
||||
* Enterprise pack, package B (calendar) against the VM 106 test Stalwarts:
|
||||
* Suggest times (6), answering for someone else (#634, 7), out-of-office
|
||||
* blocks the calendar (8), company calendars (9) and the free/busy sharing
|
||||
* the admin sets. Setup goes through the admin API and JMAP, so the spec
|
||||
* also runs against a build of main to show where it fails there.
|
||||
*/
|
||||
|
||||
requireEntEnv();
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const CORE = 'urn:ietf:params:jmap:core';
|
||||
const CAL = 'urn:ietf:params:jmap:calendars';
|
||||
const PRINC = 'urn:ietf:params:jmap:principals';
|
||||
const MAIL = 'urn:ietf:params:jmap:mail';
|
||||
const VAC = 'urn:ietf:params:jmap:vacationresponse';
|
||||
const USING = [CORE, CAL, PRINC];
|
||||
const ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const TS = Date.now().toString(36);
|
||||
const READ_WRITE = {
|
||||
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
|
||||
mayUpdatePrivate: true, mayRSVP: true, mayShare: false, mayDelete: false,
|
||||
};
|
||||
|
||||
type J = Awaited<ReturnType<typeof jmapAs>>;
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
/** A local wall clock as a JSCalendar LocalDateTime. */
|
||||
const local = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
|
||||
|
||||
function nextWorkingDay(from = new Date()): Date {
|
||||
const d = new Date(from);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
do d.setDate(d.getDate() + 1); while (d.getDay() === 0 || d.getDay() === 6);
|
||||
return d;
|
||||
}
|
||||
|
||||
async function call(j: J, calls: [string, Record<string, unknown>, string][], using = USING) {
|
||||
const res = await j.request(calls, using);
|
||||
for (const [name, result] of res.methodResponses) {
|
||||
if (name === 'error') throw new Error(`JMAP error: ${JSON.stringify(result)}`);
|
||||
}
|
||||
return res.methodResponses as [string, Record<string, unknown>, string][];
|
||||
}
|
||||
|
||||
async function defaultCalendar(j: J, accountId = j.accountId): Promise<{ id: string; name: string; shareWith: Record<string, unknown> | null }> {
|
||||
const [[, r]] = await call(j, [['Calendar/get', { accountId, properties: ['name', 'isDefault', 'shareWith'] }, '0']]);
|
||||
const list = r.list as Array<{ id: string; name: string; isDefault: boolean; shareWith: Record<string, unknown> | null }>;
|
||||
return list.find((c) => c.isDefault) ?? list[0];
|
||||
}
|
||||
|
||||
async function share(j: J, principalId: string, rights: Record<string, boolean> | null) {
|
||||
const cal = await defaultCalendar(j);
|
||||
await call(j, [['Calendar/set', { accountId: j.accountId, update: { [cal.id]: { [`shareWith/${principalId}`]: rights } } }, '0']]);
|
||||
}
|
||||
|
||||
async function createEvent(j: J, event: Record<string, unknown>, send = false, accountId = j.accountId): Promise<{ id: string; uid: string }> {
|
||||
const cal = await defaultCalendar(j, accountId);
|
||||
const [[, r]] = await call(j, [['CalendarEvent/set', {
|
||||
accountId, create: { e: { '@type': 'Event', calendarIds: { [cal.id]: true }, timeZone: ZONE, ...event } }, sendSchedulingMessages: send,
|
||||
}, '0']]);
|
||||
const created = (r.created as Record<string, { id: string }> | undefined)?.e;
|
||||
if (!created) throw new Error(`event not created: ${JSON.stringify(r)}`);
|
||||
const [[, got]] = await call(j, [['CalendarEvent/get', { accountId, ids: [created.id], properties: ['uid'] }, '0']]);
|
||||
return { id: created.id, uid: (got.list as Array<{ uid: string }>)[0].uid };
|
||||
}
|
||||
|
||||
async function findByUid(j: J, uid: string, accountId = j.accountId): Promise<string | null> {
|
||||
const [[, r]] = await call(j, [['CalendarEvent/query', { accountId, filter: { uid } }, '0']]);
|
||||
return ((r.ids as string[]) ?? [])[0] ?? null;
|
||||
}
|
||||
|
||||
async function getEvent(j: J, id: string, accountId = j.accountId) {
|
||||
const [[, r]] = await call(j, [['CalendarEvent/get', { accountId, ids: [id] }, '0']]);
|
||||
return (r.list as Array<Record<string, unknown>>)[0];
|
||||
}
|
||||
|
||||
async function destroyEvents(j: J, ids: Array<string | null | undefined>, accountId = j.accountId) {
|
||||
const list = ids.filter((id): id is string => !!id);
|
||||
if (list.length) await call(j, [['CalendarEvent/set', { accountId, destroy: list, sendSchedulingMessages: false }, '0']]).catch(() => {});
|
||||
}
|
||||
|
||||
const participant = (name: string, email: string, owner = false) => ({
|
||||
'@type': 'Participant', name, email, calendarAddress: `mailto:${email}`, kind: 'individual', scheduleAgent: 'server',
|
||||
roles: owner ? { owner: true } : { attendee: true },
|
||||
participationStatus: owner ? 'accepted' : 'needs-action', expectReply: !owner,
|
||||
});
|
||||
|
||||
/** Carol invites `attendees` to an hour starting `hoursAhead` from now; returns her event and its uid. */
|
||||
async function carolInvites(title: string, attendees: Array<[string, string]>, hoursAhead = 26) {
|
||||
const carol = await jmapAs(ENT.carol);
|
||||
const start = new Date(Date.now() + hoursAhead * 3600_000);
|
||||
start.setMinutes(0, 0, 0);
|
||||
const participants: Record<string, unknown> = { carol: participant('Ent Carol', ENT.carol, true) };
|
||||
attendees.forEach(([name, email], i) => { participants[`a${i}`] = participant(name, email); });
|
||||
const created = await createEvent(carol, {
|
||||
title, start: local(start), duration: 'PT1H', organizerCalendarAddress: `mailto:${ENT.carol}`, participants,
|
||||
}, true);
|
||||
return { ...created, carol };
|
||||
}
|
||||
|
||||
async function statusIn(j: J, eventId: string, email: string): Promise<string | undefined> {
|
||||
const event = await getEvent(j, eventId);
|
||||
const p = Object.values((event?.participants ?? {}) as Record<string, { email: string; participationStatus: string }>).find((x) => x.email === email);
|
||||
return p?.participationStatus;
|
||||
}
|
||||
|
||||
async function waitForCopy(j: J, uid: string, accountId = j.accountId): Promise<string> {
|
||||
let id: string | null = null;
|
||||
await expect.poll(async () => (id = await findByUid(j, uid, accountId)), { timeout: 30_000 }).not.toBeNull();
|
||||
return id!;
|
||||
}
|
||||
|
||||
/** Mail in carol's account whose text carries `marker`, newest first. */
|
||||
async function carolMail(marker: string) {
|
||||
const carol = await jmapAs(ENT.carol);
|
||||
const [, [, got]] = await call(carol, [
|
||||
['Email/query', { accountId: carol.accountId, filter: { text: marker }, sort: [{ property: 'receivedAt', isAscending: false }] }, 'q'],
|
||||
['Email/get', { accountId: carol.accountId, '#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' }, properties: ['subject', 'from', 'blobId'] }, 'g'],
|
||||
], [CORE, MAIL]);
|
||||
return got.list as Array<{ id: string; subject: string; blobId: string; from: Array<{ email: string }> }>;
|
||||
}
|
||||
|
||||
async function destroyMail(email: string, marker: string) {
|
||||
const j = await jmapAs(email);
|
||||
const [[, r]] = await call(j, [['Email/query', { accountId: j.accountId, filter: { text: marker } }, '0']], [CORE, MAIL]).catch(() => [[null, { ids: [] }]] as never);
|
||||
const ids = (r as { ids?: string[] }).ids ?? [];
|
||||
if (ids.length) await call(j, [['Email/set', { accountId: j.accountId, destroy: ids }, '0']], [CORE, MAIL]).catch(() => {});
|
||||
}
|
||||
|
||||
/** The agenda view: the tab on phones, the "a" shortcut elsewhere. */
|
||||
async function showAgenda(page: Page) {
|
||||
const tab = page.getByRole('button', { name: 'Agenda', exact: true }).locator('visible=true');
|
||||
if (await tab.count()) await tab.first().click();
|
||||
else await page.locator('body').press('a');
|
||||
}
|
||||
|
||||
/** Agenda view (lists what is coming up) and the event detail of `title`. */
|
||||
async function openEventInAgenda(page: Page, title: string, calendarHint?: string) {
|
||||
await openCalendar(page);
|
||||
await showAgenda(page);
|
||||
// An agenda row names the event and its calendar.
|
||||
const rows = page.locator('button', { hasText: title });
|
||||
const card = (calendarHint ? rows.filter({ hasText: calendarHint }) : rows).first();
|
||||
await expect(card).toBeVisible({ timeout: 30_000 });
|
||||
await card.click();
|
||||
}
|
||||
|
||||
async function noHorizontalScroll(page: Page) {
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
async function closePage(page: Page) {
|
||||
await page.context().close().catch(() => {});
|
||||
}
|
||||
|
||||
// ── 6. Suggest times ─────────────────────────────────────────────────
|
||||
|
||||
async function startEventWith(page: Page, attendees: string[]) {
|
||||
await openCalendar(page);
|
||||
// Toolbar on wide screens, the round + button on phones.
|
||||
await page.locator('[data-tour="create-event-button"]').locator('visible=true').first().click();
|
||||
await page.getByLabel('Title').fill(`B find ${TS}`);
|
||||
const input = page.getByPlaceholder('Add email address or search contacts');
|
||||
for (const email of attendees) {
|
||||
await input.fill(email);
|
||||
await input.press('Enter');
|
||||
}
|
||||
}
|
||||
|
||||
test('Suggest times proposes a slot free for every attendee whose free/busy is shared', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const bob = await jmapAs(ENT.bob);
|
||||
const carol = await jmapAs(ENT.carol);
|
||||
const alice = await jmapAs(ENT.alice);
|
||||
const teamId = await bob.principalIdByEmail(ENT.team);
|
||||
// Free/busy shares, the way the docs tell users and admins to set them up:
|
||||
// bob with the group alice is in, carol with alice by name.
|
||||
await share(bob, teamId, { mayReadFreeBusy: true });
|
||||
await share(carol, alice.accountId, { mayReadFreeBusy: true });
|
||||
const day = nextWorkingDay();
|
||||
const at = (h: number) => { const d = new Date(day); d.setHours(h, 0, 0, 0); return d; };
|
||||
const now = new Date();
|
||||
now.setMinutes(0, 0, 0);
|
||||
// Bob is busy from now until 11:00 on the next working day, carol 11 to 12.
|
||||
const bobBusy = await createEvent(bob, { title: `B busy ${TS}`, start: local(now), duration: `PT${Math.round((at(11).getTime() - now.getTime()) / 60000)}M`, freeBusyStatus: 'busy' });
|
||||
const carolBusy = await createEvent(carol, { title: `B busy ${TS}`, start: local(at(11)), duration: 'PT1H', freeBusyStatus: 'busy' });
|
||||
const page = await userPage(browser, ENT.alice);
|
||||
try {
|
||||
await startEventWith(page, [ENT.bob, ENT.carol, ENT.dave]);
|
||||
await page.getByTestId('find-a-time-button').click();
|
||||
const slots = page.getByTestId('find-a-time-slots').locator('button');
|
||||
await expect(slots.first()).toBeVisible({ timeout: 30_000 });
|
||||
const first = new Date((await slots.first().getAttribute('data-slot-start'))!);
|
||||
// Free for bob and carol: not before 12:00 on the next working day.
|
||||
expect(first.getTime()).toBeGreaterThanOrEqual(at(12).getTime());
|
||||
expect(first.getTime()).toBeLessThan(at(20).getTime());
|
||||
for (const [j, who] of [[bob, 'bob'], [carol, 'carol']] as const) {
|
||||
const [[, r]] = await call(alice, [['Principal/getAvailability', {
|
||||
accountId: alice.accountId, id: j.accountId, utcStart: new Date(Date.now() - 3600_000).toISOString().replace(/\.\d+Z$/, 'Z'),
|
||||
utcEnd: new Date(at(23).getTime()).toISOString().replace(/\.\d+Z$/, 'Z'),
|
||||
}, '0']], [CORE, PRINC, 'urn:ietf:params:jmap:principals:availability', CAL]);
|
||||
const busy = r.list as Array<{ utcStart: string; utcEnd: string }>;
|
||||
expect(busy.length, `${who} has busy time visible to alice`).toBeGreaterThan(0);
|
||||
for (const p of busy) {
|
||||
const overlaps = Date.parse(p.utcStart) < first.getTime() + 3600_000 && Date.parse(p.utcEnd) > first.getTime();
|
||||
expect(overlaps, `${who} busy ${p.utcStart}-${p.utcEnd}`).toBe(false);
|
||||
}
|
||||
}
|
||||
// Dave shared nothing and has nothing on: never counted as free.
|
||||
const note = page.getByTestId('find-a-time-not-checked');
|
||||
await expect(note).toContainText(ENT.dave);
|
||||
await expect(note).toContainText('busy times unknown');
|
||||
await expect(page.getByTestId('free-busy-share')).toBeVisible();
|
||||
await shot(page, 'b-find-a-time');
|
||||
// Picking the slot moves the event there.
|
||||
await slots.first().click();
|
||||
await expect(page.getByTestId('find-a-time-panel')).toContainText('Event moved to');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByTestId('find-a-time-panel')).toHaveCount(0);
|
||||
await expect(page.getByTestId('find-a-time-button')).toBeFocused();
|
||||
} finally {
|
||||
await closePage(page);
|
||||
await destroyEvents(bob, [bobBusy.id]);
|
||||
await destroyEvents(carol, [carolBusy.id]);
|
||||
await share(bob, teamId, null).catch(() => {});
|
||||
await share(carol, alice.accountId, null).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test('the suggestion list fits a phone', async ({ browser }) => {
|
||||
test.setTimeout(180_000);
|
||||
const page = await userPage(browser, ENT.alice, true);
|
||||
try {
|
||||
await startEventWith(page, [ENT.bob]);
|
||||
await page.getByTestId('find-a-time-button').click();
|
||||
await expect(page.getByTestId('find-a-time-panel')).toBeVisible();
|
||||
await expect(page.getByTestId('find-a-time-panel').getByRole('status')).not.toContainText('Looking', { timeout: 30_000 });
|
||||
await page.getByTestId('find-a-time-panel').scrollIntoViewIfNeeded();
|
||||
await noHorizontalScroll(page);
|
||||
await shot(page, 'b-find-a-time-phone');
|
||||
} finally {
|
||||
await closePage(page);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Free/busy sharing set by the admin ───────────────────────────────
|
||||
|
||||
test('the admin\'s free/busy group is shared from a user\'s default calendar at sign-in', async ({ browser }) => {
|
||||
test.setTimeout(180_000);
|
||||
const admin = await adminContext(browser);
|
||||
const carol = await jmapAs(ENT.carol);
|
||||
const teamId = await carol.principalIdByEmail(ENT.team);
|
||||
await share(carol, teamId, null).catch(() => {});
|
||||
await withPolicy(admin, { organisation: { calendars: { feeds: [], freeBusyGroup: ENT.team } } }, async () => {
|
||||
const page = await userPage(browser, ENT.carol);
|
||||
try {
|
||||
await expect.poll(async () => {
|
||||
const cal = await defaultCalendar(carol);
|
||||
return (cal.shareWith?.[teamId] as { mayReadFreeBusy?: boolean } | undefined)?.mayReadFreeBusy ?? false;
|
||||
}, { timeout: 45_000 }).toBe(true);
|
||||
// Only free/busy, and it shows in the user's sharing list.
|
||||
const cal = await defaultCalendar(carol);
|
||||
expect(cal.shareWith?.[teamId]).toMatchObject({ mayReadFreeBusy: true, mayReadItems: false, mayWriteAll: false });
|
||||
await openCalendar(page);
|
||||
await expect(calendarItem(page, cal.name).getByTestId('calendar-shared-indicator')).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await closePage(page);
|
||||
await share(carol, teamId, null).catch(() => {});
|
||||
}
|
||||
});
|
||||
await admin.close();
|
||||
});
|
||||
|
||||
// ── 7. Answering for someone else (#634) ─────────────────────────────
|
||||
|
||||
test('a delegate answers for the calendar\'s owner: the organiser gets a REPLY with SENT-BY', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const alice = await jmapAs(ENT.alice);
|
||||
const bob = await jmapAs(ENT.bob);
|
||||
const title = `B sentby ${TS}`;
|
||||
const invite = await carolInvites(title, [['Ent Alice', ENT.alice]]);
|
||||
const aliceCopy = await waitForCopy(alice, invite.uid);
|
||||
await share(alice, bob.accountId, READ_WRITE);
|
||||
const page = await userPage(browser, ENT.bob);
|
||||
try {
|
||||
await openEventInAgenda(page, title);
|
||||
const bar = page.getByTestId('answer-for-owner');
|
||||
await expect(bar).toBeVisible({ timeout: 30_000 });
|
||||
await expect(bar).toContainText('Answer for');
|
||||
await shot(page, 'b-answer-for');
|
||||
await bar.getByRole('button', { name: 'Accept' }).click();
|
||||
|
||||
// Carol's inbox: the REPLY from alice with bob in SENT-BY.
|
||||
let raw = '';
|
||||
await expect.poll(async () => {
|
||||
for (const m of await carolMail(title)) {
|
||||
const text = (await rawMessage(ENT.carol, m.blobId)).replace(/\r?\n[ \t]/g, '');
|
||||
if (/METHOD:REPLY/.test(text)) { raw = text; return true; }
|
||||
}
|
||||
return false;
|
||||
}, { timeout: 60_000 }).toBe(true);
|
||||
const attendee = raw.split(/\r?\n/).find((l) => l.startsWith('ATTENDEE') && /ent-alice@example\.org/i.test(l)) ?? '';
|
||||
expect(attendee).toMatch(/PARTSTAT=ACCEPTED/);
|
||||
expect(attendee).toMatch(/SENT-BY="mailto:ent-bob@example\.org"/i);
|
||||
expect(raw).toMatch(/^From:.*ent-alice@example\.org/im);
|
||||
// Her copy changes alice.
|
||||
await expect.poll(async () => {
|
||||
const event = await getEvent(invite.carol, invite.id);
|
||||
const p = Object.values(event.participants as Record<string, { email: string; participationStatus: string }>).find((x) => x.email === ENT.alice);
|
||||
return p?.participationStatus;
|
||||
}, { timeout: 30_000 }).toBe('accepted');
|
||||
// Alice's own view says who answered.
|
||||
const ownerView = await getEvent(alice, aliceCopy);
|
||||
const aliceP = Object.values(ownerView.participants as Record<string, { email: string; sentBy?: string }>).find((x) => x.email === ENT.alice);
|
||||
expect(aliceP?.sentBy).toBe(`mailto:${ENT.bob}`);
|
||||
} finally {
|
||||
await closePage(page);
|
||||
await share(alice, bob.accountId, null).catch(() => {});
|
||||
await destroyEvents(invite.carol, [invite.id]);
|
||||
await destroyEvents(alice, [aliceCopy]);
|
||||
await destroyMail(ENT.carol, title);
|
||||
await destroyMail(ENT.alice, title);
|
||||
}
|
||||
});
|
||||
|
||||
test('with the delegate invited too: both answers on 1.0, the owner\'s refused on 0.16', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const alice = await jmapAs(ENT.alice);
|
||||
const bob = await jmapAs(ENT.bob);
|
||||
const title = `B both ${TS}`;
|
||||
const invite = await carolInvites(title, [['Ent Alice', ENT.alice], ['Ent Bob', ENT.bob]], 28);
|
||||
const aliceCopy = await waitForCopy(alice, invite.uid);
|
||||
const bobCopy = await waitForCopy(bob, invite.uid);
|
||||
await share(alice, bob.accountId, READ_WRITE);
|
||||
const page = await userPage(browser, ENT.bob);
|
||||
try {
|
||||
// The copy in alice's calendar; bob's own is listed too.
|
||||
await openEventInAgenda(page, title, ENT.alice);
|
||||
if (ENT_SERVER === '0.16') {
|
||||
await expect(page.getByTestId('answer-note-also_invited')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('answer-for-owner')).toHaveCount(0);
|
||||
} else {
|
||||
await expect(page.getByTestId('answer-for-owner')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId('answer-self')).toBeVisible();
|
||||
}
|
||||
await shot(page, 'b-answer-both-invited');
|
||||
if (ENT_SERVER !== '0.16') {
|
||||
// Both answers: alice accepts through bob, bob declines for himself.
|
||||
await page.getByTestId('answer-for-owner').getByRole('button', { name: 'Accept' }).click();
|
||||
await expect.poll(async () => statusIn(invite.carol, invite.id, ENT.alice), { timeout: 60_000 }).toBe('accepted');
|
||||
await openEventInAgenda(page, title, ENT.alice);
|
||||
await page.getByTestId('answer-self').getByRole('button', { name: 'Decline' }).click();
|
||||
// Carol's copy changed the right attendee each time.
|
||||
await expect.poll(async () => statusIn(invite.carol, invite.id, ENT.bob), { timeout: 60_000 }).toBe('declined');
|
||||
expect(await statusIn(invite.carol, invite.id, ENT.alice)).toBe('accepted');
|
||||
}
|
||||
} finally {
|
||||
await closePage(page);
|
||||
await share(alice, bob.accountId, null).catch(() => {});
|
||||
await destroyEvents(invite.carol, [invite.id]);
|
||||
await destroyEvents(alice, [aliceCopy]);
|
||||
await destroyEvents(bob, [bobCopy]);
|
||||
await destroyMail(ENT.carol, title);
|
||||
}
|
||||
});
|
||||
|
||||
test('a member answers for a group mailbox as the group, without SENT-BY', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const alice = await jmapAs(ENT.alice);
|
||||
const title = `B group ${TS}`;
|
||||
const teamAccount = alice.accountIdByName(ENT.team);
|
||||
const invite = await carolInvites(title, [['Ent Team', ENT.team]], 30);
|
||||
const teamCopy = await waitForCopy(alice, invite.uid, teamAccount);
|
||||
const page = await userPage(browser, ENT.alice);
|
||||
try {
|
||||
await openEventInAgenda(page, title);
|
||||
const bar = page.getByTestId('answer-for-owner');
|
||||
await expect(bar).toBeVisible({ timeout: 30_000 });
|
||||
await bar.getByRole('button', { name: 'Accept' }).click();
|
||||
let raw = '';
|
||||
await expect.poll(async () => {
|
||||
for (const m of await carolMail(title)) {
|
||||
const text = (await rawMessage(ENT.carol, m.blobId)).replace(/\r?\n[ \t]/g, '');
|
||||
if (/METHOD:REPLY/.test(text)) { raw = text; return true; }
|
||||
}
|
||||
return false;
|
||||
}, { timeout: 60_000 }).toBe(true);
|
||||
const attendee = raw.split(/\r?\n/).find((l) => l.startsWith('ATTENDEE') && /ent-team@example\.org/i.test(l)) ?? '';
|
||||
expect(attendee).toMatch(/PARTSTAT=ACCEPTED/);
|
||||
expect(attendee).not.toMatch(/SENT-BY/i);
|
||||
expect(raw).toMatch(/^From:.*ent-team@example\.org/im);
|
||||
} finally {
|
||||
await closePage(page);
|
||||
await destroyEvents(invite.carol, [invite.id]);
|
||||
await destroyEvents(alice, [teamCopy], teamAccount);
|
||||
await destroyMail(ENT.carol, title);
|
||||
}
|
||||
});
|
||||
|
||||
test('an invitation in the team mailbox is answered from the banner as the team, with no copy in the member\'s own calendar', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const alice = await jmapAs(ENT.alice);
|
||||
const title = `B banner ${TS}`;
|
||||
const teamAccount = alice.accountIdByName(ENT.team);
|
||||
const invite = await carolInvites(title, [['Ent Team', ENT.team]], 34);
|
||||
const teamCopy = await waitForCopy(alice, invite.uid, teamAccount);
|
||||
const page = await userPage(browser, ENT.alice);
|
||||
try {
|
||||
await expandSharedFolders(page, ENT.team);
|
||||
await folderRow(page, { role: 'inbox', shared: true }).first().click();
|
||||
const row = page.locator('[data-testid="email-list-item"]', { hasText: title }).first();
|
||||
await expect(row).toBeVisible({ timeout: 60_000 });
|
||||
await row.click();
|
||||
await page.getByRole('button', { name: 'Accept', exact: true }).first().click();
|
||||
let raw = '';
|
||||
await expect.poll(async () => {
|
||||
for (const m of await carolMail(title)) {
|
||||
const text = (await rawMessage(ENT.carol, m.blobId)).replace(/\r?\n[ \t]/g, '');
|
||||
if (/METHOD:REPLY/.test(text)) { raw = text; return true; }
|
||||
}
|
||||
return false;
|
||||
}, { timeout: 60_000 }).toBe(true);
|
||||
const attendee = raw.split(/\r?\n/).find((l) => l.startsWith('ATTENDEE') && /ent-team@example\.org/i.test(l)) ?? '';
|
||||
expect(attendee).toMatch(/PARTSTAT=ACCEPTED/);
|
||||
expect(attendee).not.toMatch(/SENT-BY/i);
|
||||
expect(raw).toMatch(/^From:.*ent-team@example\.org/im);
|
||||
// No duplicate in alice's own calendar (#634).
|
||||
expect(await findByUid(alice, invite.uid)).toBeNull();
|
||||
} finally {
|
||||
await closePage(page);
|
||||
await destroyEvents(invite.carol, [invite.id]);
|
||||
await destroyEvents(alice, [teamCopy], teamAccount);
|
||||
const stray = await findByUid(alice, invite.uid).catch(() => null);
|
||||
await destroyEvents(alice, [stray]);
|
||||
await destroyMail(ENT.carol, title);
|
||||
}
|
||||
});
|
||||
|
||||
test('the answer bars fit a phone', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const alice = await jmapAs(ENT.alice);
|
||||
const bob = await jmapAs(ENT.bob);
|
||||
const title = `B phone ${TS}`;
|
||||
const invite = await carolInvites(title, [['Ent Alice', ENT.alice]], 32);
|
||||
const aliceCopy = await waitForCopy(alice, invite.uid);
|
||||
await share(alice, bob.accountId, READ_WRITE);
|
||||
const page = await userPage(browser, ENT.bob, true);
|
||||
try {
|
||||
await openEventInAgenda(page, title);
|
||||
await expect(page.getByTestId('answer-for-owner')).toBeVisible({ timeout: 30_000 });
|
||||
await noHorizontalScroll(page);
|
||||
const box = await page.getByTestId('answer-for-owner').boundingBox();
|
||||
expect(box && box.x + box.width).toBeLessThanOrEqual(390);
|
||||
await shot(page, 'b-answer-for-phone');
|
||||
} finally {
|
||||
await closePage(page);
|
||||
await share(alice, bob.accountId, null).catch(() => {});
|
||||
await destroyEvents(invite.carol, [invite.id]);
|
||||
await destroyEvents(alice, [aliceCopy]);
|
||||
await destroyMail(ENT.carol, title);
|
||||
}
|
||||
});
|
||||
|
||||
// ── 8. Out-of-office blocks the calendar ─────────────────────────────
|
||||
|
||||
async function oooEvent(alice: J) {
|
||||
const id = await findByUid(alice, `bulwark-ooo-${alice.accountId}`);
|
||||
return id ? getEvent(alice, id) : null;
|
||||
}
|
||||
|
||||
const datetimeLocal = (d: Date) => local(d).slice(0, 16);
|
||||
|
||||
test('the out-of-office busy event appears, moves with the dates and goes away when switched off', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const alice = await jmapAs(ENT.alice);
|
||||
const resetReply = () => call(alice, [['VacationResponse/set', { accountId: alice.accountId, update: { singleton: { isEnabled: false, fromDate: null, toDate: null } } }, '0']], [CORE, VAC]);
|
||||
await resetReply();
|
||||
const leftover = await findByUid(alice, `bulwark-ooo-${alice.accountId}`);
|
||||
await destroyEvents(alice, [leftover]);
|
||||
const from = new Date(); from.setDate(from.getDate() + 10); from.setHours(9, 0, 0, 0);
|
||||
const to = new Date(); to.setDate(to.getDate() + 12); to.setHours(17, 0, 0, 0);
|
||||
const later = new Date(to); later.setDate(later.getDate() + 2);
|
||||
const page = await userPage(browser, ENT.alice);
|
||||
try {
|
||||
await page.goto('/settings/vacation');
|
||||
const dates = page.locator('input[type="datetime-local"]');
|
||||
await dates.nth(0).fill(datetimeLocal(from));
|
||||
await dates.nth(1).fill(datetimeLocal(to));
|
||||
const block = page.getByTestId('vacation-block-calendar');
|
||||
await expect(block).toBeChecked();
|
||||
await page.locator('[data-settings-section="reply"]').getByRole('switch').first().click();
|
||||
|
||||
await expect.poll(async () => (await oooEvent(alice))?.start ?? null, { timeout: 45_000 }).toBe(local(from));
|
||||
const event = (await oooEvent(alice))!;
|
||||
expect(event.freeBusyStatus).toBe('busy');
|
||||
expect(event.participants ?? null).toBeNull();
|
||||
await shot(page, 'b-ooo-settings');
|
||||
|
||||
// New end date: the event moves along.
|
||||
await dates.nth(1).fill(datetimeLocal(later));
|
||||
await expect.poll(async () => (await oooEvent(alice))?.duration ?? null, { timeout: 45_000 }).toBe('P4DT8H');
|
||||
|
||||
// Reply off before it started: the event goes away.
|
||||
await page.locator('[data-settings-section="reply"]').getByRole('switch').first().click();
|
||||
await expect.poll(async () => await oooEvent(alice), { timeout: 45_000 }).toBeNull();
|
||||
} finally {
|
||||
await closePage(page);
|
||||
await resetReply().catch(() => {});
|
||||
const left = await findByUid(alice, `bulwark-ooo-${alice.accountId}`).catch(() => null);
|
||||
await destroyEvents(alice, [left]);
|
||||
}
|
||||
});
|
||||
|
||||
test('the out-of-office checkbox fits a phone', async ({ browser }) => {
|
||||
test.setTimeout(180_000);
|
||||
const alice = await jmapAs(ENT.alice);
|
||||
const from = new Date(); from.setDate(from.getDate() + 20); from.setHours(9, 0, 0, 0);
|
||||
const to = new Date(from); to.setDate(to.getDate() + 1);
|
||||
await call(alice, [['VacationResponse/set', { accountId: alice.accountId, update: { singleton: {
|
||||
isEnabled: false, fromDate: from.toISOString().replace(/\.\d+Z$/, 'Z'), toDate: to.toISOString().replace(/\.\d+Z$/, 'Z'),
|
||||
} } }, '0']], [CORE, VAC]);
|
||||
const page = await userPage(browser, ENT.alice, true);
|
||||
try {
|
||||
await page.goto('/settings/vacation');
|
||||
// Phone width opens the settings list first.
|
||||
const tab = page.getByRole('button', { name: /Vacation|Out of office/i }).first();
|
||||
if (await tab.isVisible().catch(() => false)) await tab.click();
|
||||
const block = page.getByTestId('vacation-block-calendar');
|
||||
await expect(block).toBeVisible({ timeout: 30_000 });
|
||||
await block.scrollIntoViewIfNeeded();
|
||||
await noHorizontalScroll(page);
|
||||
await shot(page, 'b-ooo-phone');
|
||||
} finally {
|
||||
await closePage(page);
|
||||
await call(alice, [['VacationResponse/set', { accountId: alice.accountId, update: { singleton: { isEnabled: false, fromDate: null, toDate: null } } }, '0']], [CORE, VAC]).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test('an organisation lock that never blocks the calendar shows locked and creates no event', async ({ browser }) => {
|
||||
test.setTimeout(180_000);
|
||||
const alice = await jmapAs(ENT.alice);
|
||||
const admin = await adminContext(browser);
|
||||
const from = new Date(); from.setDate(from.getDate() + 15); from.setHours(9, 0, 0, 0);
|
||||
const to = new Date(from); to.setDate(to.getDate() + 2);
|
||||
const resetReply = () => call(alice, [['VacationResponse/set', { accountId: alice.accountId, update: { singleton: { isEnabled: false, fromDate: null, toDate: null } } }, '0']], [CORE, VAC]);
|
||||
await destroyEvents(alice, [await findByUid(alice, `bulwark-ooo-${alice.accountId}`)]);
|
||||
await call(alice, [['VacationResponse/set', { accountId: alice.accountId, update: { singleton: {
|
||||
isEnabled: false, fromDate: from.toISOString().replace(/\.\d+Z$/, 'Z'), toDate: to.toISOString().replace(/\.\d+Z$/, 'Z'),
|
||||
} } }, '0']], [CORE, VAC]);
|
||||
try {
|
||||
await withPolicy(admin, { restrictions: { vacationBlockCalendar: { locked: true, value: false } } }, async () => {
|
||||
const page = await userPage(browser, ENT.alice);
|
||||
try {
|
||||
await page.goto('/settings/vacation');
|
||||
const block = page.getByTestId('vacation-block-calendar');
|
||||
await expect(block).toBeVisible({ timeout: 30_000 });
|
||||
await expect(block).not.toBeChecked();
|
||||
await expect(page.getByText('Set by your organisation').first()).toBeVisible();
|
||||
await page.locator('[data-settings-section="reply"]').getByRole('switch').first().click();
|
||||
await expect.poll(async () => (await call(alice, [['VacationResponse/get', { accountId: alice.accountId, ids: ['singleton'] }, '0']], [CORE, VAC]))[0][1].list[0].isEnabled, { timeout: 30_000 }).toBe(true);
|
||||
// Well past the sync's pause: still no busy event.
|
||||
await page.waitForTimeout(6000);
|
||||
expect(await findByUid(alice, `bulwark-ooo-${alice.accountId}`)).toBeNull();
|
||||
await shot(page, 'b-ooo-locked');
|
||||
} finally {
|
||||
await closePage(page);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await admin.close();
|
||||
await resetReply().catch(() => {});
|
||||
await destroyEvents(alice, [await findByUid(alice, `bulwark-ooo-${alice.accountId}`).catch(() => null)]);
|
||||
}
|
||||
});
|
||||
|
||||
// ── 9. Company calendars ─────────────────────────────────────────────
|
||||
// The feed is served from this PC's intranet address, the case of a feed on
|
||||
// the company intranet.
|
||||
|
||||
function icsServer(title: string): Promise<{ server: Server; url: string; hits: () => number }> {
|
||||
let hits = 0;
|
||||
const day = nextWorkingDay();
|
||||
const ymd = `${day.getFullYear()}${pad(day.getMonth() + 1)}${pad(day.getDate())}`;
|
||||
const body = [
|
||||
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Bulwark spec//EN',
|
||||
'BEGIN:VEVENT', `UID:b-company-${TS}@example.org`, `DTSTAMP:${ymd}T000000Z`, `DTSTART;VALUE=DATE:${ymd}`, `SUMMARY:${title}`, 'END:VEVENT',
|
||||
'END:VCALENDAR', '',
|
||||
].join('\r\n');
|
||||
const server = createServer((_req, res) => {
|
||||
hits++;
|
||||
res.writeHead(200, { 'content-type': 'text/calendar' });
|
||||
res.end(body);
|
||||
});
|
||||
// An intranet address of this PC: the server reaches admin feeds on the
|
||||
// intranet, never on loopback.
|
||||
const lan = Object.values(networkInterfaces()).flat()
|
||||
.find((i) => i && i.family === 'IPv4' && !i.internal && /^(10.|192.168.|172.(1[6-9]|2d|3[01]).)/.test(i.address))?.address;
|
||||
if (!lan) throw new Error('no intranet IPv4 address on this PC for the feed server');
|
||||
return new Promise((resolve) => server.listen(0, lan, () => {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
resolve({ server, url: `http://${lan}:${port}/team.ics`, hits: () => hits });
|
||||
}));
|
||||
}
|
||||
|
||||
test('a company calendar scoped to a group shows for its members only, and its address stays on the server', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const title = `B company ${TS}`;
|
||||
const feed = await icsServer(title);
|
||||
const admin = await adminContext(browser);
|
||||
const scopes = [{
|
||||
id: 'b-team', label: 'B team', match: { groups: [ENT.team] },
|
||||
policy: { organisation: { calendars: { feeds: [{ id: 'b-team-feed', name: 'B Team calendar', url: feed.url, color: '#16a34a', refreshMinutes: 60 }] } } },
|
||||
}];
|
||||
try {
|
||||
await withPolicy(admin, { scopes }, async () => {
|
||||
const alicePage = await userPage(browser, ENT.alice);
|
||||
try {
|
||||
await openCalendar(alicePage);
|
||||
await expect(alicePage.getByTestId('company-calendars-section')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(calendarItem(alicePage, 'B Team calendar')).toBeVisible();
|
||||
await showAgenda(alicePage);
|
||||
await expect(alicePage.getByText(title).first()).toBeVisible({ timeout: 45_000 });
|
||||
expect(feed.hits()).toBeGreaterThan(0);
|
||||
// The policy alice's browser gets has no address.
|
||||
const policy = await (await alicePage.context().request.get('/api/admin/policy')).text();
|
||||
expect(policy).not.toContain(feed.url);
|
||||
expect(policy).toContain('B Team calendar');
|
||||
await shot(alicePage, 'b-company-calendar');
|
||||
// Hiding it takes its events off the grid.
|
||||
await calendarItem(alicePage, 'B Team calendar').click();
|
||||
await expect(alicePage.getByText(title)).toHaveCount(0);
|
||||
await calendarItem(alicePage, 'B Team calendar').click();
|
||||
} finally {
|
||||
await closePage(alicePage);
|
||||
}
|
||||
const carolPage = await userPage(browser, ENT.carol);
|
||||
try {
|
||||
await openCalendar(carolPage);
|
||||
await expect(carolPage.locator('[data-testid="calendar-item"]').first()).toBeVisible();
|
||||
await expect(calendarItem(carolPage, 'B Team calendar')).toHaveCount(0);
|
||||
await expect(carolPage.getByTestId('company-calendars-section')).toHaveCount(0);
|
||||
// Not by id either: the server resolves carol's scopes itself.
|
||||
const res = await carolPage.context().request.post('/api/fetch-ical', {
|
||||
data: { feedId: 'b-team-feed' }, headers: { Origin: new URL(carolPage.url()).origin },
|
||||
});
|
||||
expect(res.status()).toBe(404);
|
||||
} finally {
|
||||
await closePage(carolPage);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await admin.close();
|
||||
feed.server.close();
|
||||
}
|
||||
});
|
||||
|
||||
// ── The admin page ───────────────────────────────────────────────────
|
||||
|
||||
async function adminPage(browser: Browser, phone: boolean): Promise<Page> {
|
||||
const context = await adminContext(browser, phone);
|
||||
const page = await context.newPage();
|
||||
await page.goto('/admin/org-calendars');
|
||||
await expect(page.getByText('Company calendars').first()).toBeVisible({ timeout: 30_000 });
|
||||
return page;
|
||||
}
|
||||
|
||||
test('Organisation > Calendars saves company calendars, the free/busy group and the out-of-office lock', async ({ browser }) => {
|
||||
test.setTimeout(180_000);
|
||||
const page = await adminPage(browser, false);
|
||||
const saved = await getPolicy(page.context());
|
||||
try {
|
||||
await page.getByTestId('org-feed-add').click();
|
||||
await page.getByTestId('org-feed-name').last().fill('B Holidays');
|
||||
await page.getByTestId('org-feed-url').last().fill('webcal://calendar.example.org/holidays.ics');
|
||||
await page.getByLabel('Share free/busy with (group address)').fill(ENT.team);
|
||||
await page.getByLabel('Out-of-office blocks the calendar').selectOption('never');
|
||||
await page.getByRole('button', { name: 'Save' }).last().click();
|
||||
await expect.poll(async () => {
|
||||
const policy = await getPolicy(page.context()) as {
|
||||
organisation?: { calendars?: { feeds?: Array<{ name: string; url: string }>; freeBusyGroup?: string } };
|
||||
restrictions?: Record<string, unknown>;
|
||||
};
|
||||
return {
|
||||
feed: policy.organisation?.calendars?.feeds?.find((f) => f.name === 'B Holidays')?.url,
|
||||
group: policy.organisation?.calendars?.freeBusyGroup,
|
||||
lock: policy.restrictions?.vacationBlockCalendar,
|
||||
};
|
||||
}, { timeout: 20_000 }).toEqual({
|
||||
feed: 'https://calendar.example.org/holidays.ics',
|
||||
group: ENT.team,
|
||||
lock: { locked: true, value: false },
|
||||
});
|
||||
await shot(page, 'b-admin-calendars');
|
||||
} finally {
|
||||
await putPolicy(page.context(), saved);
|
||||
await page.context().close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Organisation > Calendars fits a phone', async ({ browser }) => {
|
||||
test.setTimeout(120_000);
|
||||
const page = await adminPage(browser, true);
|
||||
try {
|
||||
await noHorizontalScroll(page);
|
||||
await shot(page, 'b-admin-calendars-phone');
|
||||
} finally {
|
||||
await page.context().close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { accountOwnerOf, resolveAnswerTargets, sentByPatchValue, type ResolveAnswerInput } from '../calendar-delegate';
|
||||
import { CALENDAR_PRESETS } from '@/lib/jmap/share-rights';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
const participant = (email: string, extra: Record<string, unknown> = {}) => ({
|
||||
'@type': 'Participant', name: email.split('@')[0], email, calendarAddress: `mailto:${email}`,
|
||||
roles: { attendee: true }, participationStatus: 'needs-action', ...extra,
|
||||
});
|
||||
|
||||
function invitation(attendees: string[], extra: Partial<CalendarEvent> = {}): CalendarEvent {
|
||||
const participants: Record<string, unknown> = {
|
||||
carol: participant('carol@example.org', { roles: { owner: true }, participationStatus: 'accepted' }),
|
||||
};
|
||||
for (const a of attendees) participants[a.split('@')[0]] = participant(a);
|
||||
return {
|
||||
id: 'alice:e1', uid: 'u1', title: 'Planning', start: '2026-11-04T14:00:00', duration: 'PT1H',
|
||||
calendarIds: { 'alice:b': true }, isShared: true, accountId: 'alice',
|
||||
organizerCalendarAddress: 'mailto:carol@example.org',
|
||||
participants, ...extra,
|
||||
} as unknown as CalendarEvent;
|
||||
}
|
||||
|
||||
const ALICE = accountOwnerOf({ id: 'alice', type: 'individual', name: 'alice@example.org', description: 'Alice Doe', email: 'alice@example.org' }, 'alice@example.org')!;
|
||||
const TEAM = accountOwnerOf({ id: 'team', type: 'group', name: 'team@example.org', description: 'Team', email: 'team@example.org' }, 'team@example.org')!;
|
||||
const write = { isShared: true, myRights: CALENDAR_PRESETS.readWrite };
|
||||
const base = (event: CalendarEvent, extra: Partial<ResolveAnswerInput> = {}): ResolveAnswerInput => ({
|
||||
event, calendar: write, userEmails: ['bob@example.org'], canRsvpSelf: false, owner: ALICE, server016: false, ...extra,
|
||||
});
|
||||
|
||||
describe('resolveAnswerTargets', () => {
|
||||
it('answers for the owner of a shared calendar with SENT-BY mailto:<delegate>', () => {
|
||||
const view = resolveAnswerTargets(base(invitation(['alice@example.org'])));
|
||||
expect(view.note).toBeNull();
|
||||
expect(view.targets).toEqual([{
|
||||
kind: 'owner', eventId: 'alice:e1', participantId: 'alice', status: 'needs-action',
|
||||
ownerName: 'Alice Doe', sentBy: 'mailto:bob@example.org',
|
||||
}]);
|
||||
});
|
||||
|
||||
it('answers as a group mailbox without SENT-BY', () => {
|
||||
const view = resolveAnswerTargets(base(invitation(['team@example.org'], { accountId: 'team' }), { owner: TEAM }));
|
||||
expect(view.targets).toHaveLength(1);
|
||||
expect(view.targets[0]).toMatchObject({ kind: 'owner', participantId: 'team', ownerName: 'Team' });
|
||||
expect(view.targets[0].sentBy).toBeUndefined();
|
||||
});
|
||||
|
||||
it('answers as the group also when the member has the group address as an identity', () => {
|
||||
const view = resolveAnswerTargets(base(invitation(['team@example.org'], { accountId: 'team' }), {
|
||||
owner: TEAM, userEmails: ['bob@example.org', 'team@example.org'], canRsvpSelf: true,
|
||||
}));
|
||||
expect(view.targets.map((t) => [t.kind, t.participantId, t.sentBy])).toEqual([['owner', 'team', undefined]]);
|
||||
});
|
||||
|
||||
it('offers no answer for the owner without edit access, and says why', () => {
|
||||
for (const rights of [CALENDAR_PRESETS.read, { ...CALENDAR_PRESETS.read, mayRSVP: true }]) {
|
||||
const view = resolveAnswerTargets(base(invitation(['alice@example.org']), { calendar: { isShared: true, myRights: rights } }));
|
||||
expect(view.targets).toEqual([]);
|
||||
expect(view.note).toEqual({ kind: 'no_write', owner: 'Alice Doe' });
|
||||
}
|
||||
});
|
||||
|
||||
it('shows both answers on 1.0 when the user is invited too: the owner\'s here, the user\'s on their own copy', () => {
|
||||
const shared = invitation(['alice@example.org', 'bob@example.org']);
|
||||
const own = { ...invitation(['alice@example.org', 'bob@example.org']), id: 'e7', isShared: false, accountId: undefined } as CalendarEvent;
|
||||
const view = resolveAnswerTargets(base(shared, { canRsvpSelf: true, ownCopy: own }));
|
||||
expect(view.targets.map((t) => [t.kind, t.eventId, t.participantId])).toEqual([
|
||||
['owner', 'alice:e1', 'alice'],
|
||||
['self', 'e7', 'bob'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses the owner\'s answer on 0.16 when the user is invited too', () => {
|
||||
// 0.16 answered "Cannot modify address of the event" live: it counts the
|
||||
// delegate's and the owner's addresses as its own.
|
||||
const shared = invitation(['alice@example.org', 'bob@example.org']);
|
||||
const own = { ...shared, id: 'e7', isShared: false } as CalendarEvent;
|
||||
const view = resolveAnswerTargets(base(shared, { canRsvpSelf: true, ownCopy: own, server016: true }));
|
||||
expect(view.note).toEqual({ kind: 'also_invited', owner: 'Alice Doe' });
|
||||
expect(view.targets.map((t) => [t.kind, t.eventId])).toEqual([['self', 'e7']]);
|
||||
// Only invited alone, 0.16 answers for the owner as 1.0 does.
|
||||
expect(resolveAnswerTargets(base(invitation(['alice@example.org']), { server016: true })).targets[0].kind).toBe('owner');
|
||||
});
|
||||
|
||||
it('says who answered for the owner, and for the user in their own calendar', () => {
|
||||
const shared = invitation(['alice@example.org']);
|
||||
(shared.participants!.alice as { sentBy?: string }).sentBy = 'mailto:bob@example.org';
|
||||
expect(resolveAnswerTargets(base(shared)).answeredBy).toBe('bob@example.org');
|
||||
const own = { ...shared, isShared: false } as CalendarEvent;
|
||||
const view = resolveAnswerTargets({ event: own, calendar: { isShared: false, myRights: CALENDAR_PRESETS.manager }, userEmails: ['alice@example.org'], canRsvpSelf: true, owner: null, server016: false });
|
||||
expect(view.targets.map((t) => t.kind)).toEqual(['self']);
|
||||
expect(view.answeredBy).toBe('bob@example.org');
|
||||
});
|
||||
|
||||
it('keeps the existing behaviour where the owner is the organiser or not invited', () => {
|
||||
const organised = invitation(['dave@example.org'], { organizerCalendarAddress: 'mailto:alice@example.org' });
|
||||
expect(resolveAnswerTargets(base(organised)).targets).toEqual([]);
|
||||
const notInvited = invitation(['dave@example.org']);
|
||||
expect(resolveAnswerTargets(base(notInvited)).targets).toEqual([]);
|
||||
expect(resolveAnswerTargets(base(notInvited)).note).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accountOwnerOf', () => {
|
||||
it('falls back to the account name when the principal is unknown', () => {
|
||||
expect(accountOwnerOf(null, 'Alice@Example.org')).toEqual({ name: 'Alice@Example.org', emails: ['alice@example.org'], isGroup: false });
|
||||
expect(accountOwnerOf(null, 'shared-calendar')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sentByPatchValue', () => {
|
||||
it('sets, clears or leaves sentBy', () => {
|
||||
expect(sentByPatchValue({}, 'mailto:bob@example.org')).toBe('mailto:bob@example.org');
|
||||
expect(sentByPatchValue({ sentBy: 'mailto:bob@example.org' }, undefined)).toBeNull();
|
||||
expect(sentByPatchValue({}, undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const lite = vi.hoisted(() => ({ value: false }));
|
||||
vi.mock('@/lib/lite', async (original) => ({
|
||||
...(await original<typeof import('@/lib/lite')>()),
|
||||
get IS_LITE() { return lite.value; },
|
||||
}));
|
||||
const apiFetch = vi.fn();
|
||||
vi.mock('@/lib/browser-navigation', async (original) => ({
|
||||
...(await original<typeof import('@/lib/browser-navigation')>()),
|
||||
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth/active-account-slot', () => ({ getActiveAccountSlotHeaders: () => ({ 'X-JMAP-Cookie-Slot': '1' }) }));
|
||||
|
||||
import {
|
||||
companyCalendarId, createCompanyCalendar, isCompanyCalendarId, loadFeedEvents, resetCompanyCalendarMemory, toCompanyEvents,
|
||||
} from '../company-calendars';
|
||||
import { purgeSignedOutData, runBeforeSignOut } from '../sign-out-cleanup';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import type { CompanyFeed } from '@/lib/admin/organisation-policy';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
const FEED: CompanyFeed = { id: 'hol', name: 'Holidays', url: '', urlHeld: true, color: '#16a34a', refreshMinutes: 60 };
|
||||
const ICS = 'BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:1\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n';
|
||||
const PARSED = [{ uid: '1', title: 'Closed', start: '2026-12-24T00:00:00', duration: 'P1D', showWithoutTime: true }];
|
||||
|
||||
function fakeClient() {
|
||||
return {
|
||||
getCalendarsAccountId: () => 'alice',
|
||||
uploadBlob: vi.fn(async () => ({ blobId: 'blob1', size: 1, type: 'text/calendar' })),
|
||||
parseCalendarEvents: vi.fn(async () => PARSED as Partial<CalendarEvent>[]),
|
||||
};
|
||||
}
|
||||
|
||||
const HOUR = 3600_000;
|
||||
const T0 = Date.parse('2026-11-02T08:00:00Z');
|
||||
|
||||
beforeEach(() => {
|
||||
resetCompanyCalendarMemory();
|
||||
apiFetch.mockReset();
|
||||
apiFetch.mockImplementation(async () => new Response(ICS));
|
||||
lite.value = false;
|
||||
// No IndexedDB in jsdom: memory only.
|
||||
});
|
||||
|
||||
describe('loadFeedEvents', () => {
|
||||
it('fetches the feed by id through the server, uploads it on the XHR path and parses it', async () => {
|
||||
const client = fakeClient();
|
||||
expect(await loadFeedEvents(client, 'alice', FEED, T0)).toEqual(PARSED);
|
||||
expect(apiFetch).toHaveBeenCalledWith('/api/fetch-ical', expect.objectContaining({ method: 'POST', body: JSON.stringify({ feedId: 'hol' }) }));
|
||||
// The server resolves the feed for the active account's sign-in slot.
|
||||
expect(apiFetch.mock.calls[0][1].headers).toMatchObject({ 'X-JMAP-Cookie-Slot': '1' });
|
||||
const [, options] = client.uploadBlob.mock.calls[0] as unknown as [File, { accountId: string; onProgress?: unknown }];
|
||||
expect(options.accountId).toBe('alice');
|
||||
expect(typeof options.onProgress).toBe('function');
|
||||
expect(client.parseCalendarEvents).toHaveBeenCalledWith('alice', 'blob1');
|
||||
});
|
||||
|
||||
it('asks again only after the refresh interval, and uploads only a feed that changed', async () => {
|
||||
const client = fakeClient();
|
||||
await loadFeedEvents(client, 'alice', FEED, T0);
|
||||
await loadFeedEvents(client, 'alice', FEED, T0 + 30 * 60_000);
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
await loadFeedEvents(client, 'alice', FEED, T0 + HOUR + 1);
|
||||
expect(apiFetch).toHaveBeenCalledTimes(2);
|
||||
expect(client.uploadBlob).toHaveBeenCalledTimes(1);
|
||||
|
||||
apiFetch.mockImplementation(async () => new Response(ICS.replace('UID:1', 'UID:2')));
|
||||
await loadFeedEvents(client, 'alice', FEED, T0 + 2 * HOUR + 2);
|
||||
expect(client.uploadBlob).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps the last events when the feed can\'t be fetched', async () => {
|
||||
const client = fakeClient();
|
||||
await loadFeedEvents(client, 'alice', FEED, T0);
|
||||
apiFetch.mockImplementation(async () => new Response('down', { status: 502 }));
|
||||
expect(await loadFeedEvents(client, 'alice', FEED, T0 + 2 * HOUR)).toEqual(PARSED);
|
||||
expect(await loadFeedEvents(fakeClient(), 'bob', FEED, T0)).toEqual([]);
|
||||
});
|
||||
|
||||
it('fetches the address itself in Lite, where there is no server', async () => {
|
||||
lite.value = true;
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(ICS));
|
||||
try {
|
||||
await loadFeedEvents(fakeClient(), 'alice', { ...FEED, url: 'https://cal.example.org/h.ics', urlHeld: undefined }, T0);
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://cal.example.org/h.ics', expect.objectContaining({ credentials: 'omit' }));
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('forgets every feed on a full sign-out', async () => {
|
||||
const client = fakeClient();
|
||||
await loadFeedEvents(client, 'alice', FEED, T0);
|
||||
purgeSignedOutData();
|
||||
await loadFeedEvents(client, 'alice', FEED, T0 + 60_000);
|
||||
expect(apiFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the company calendar and its events', () => {
|
||||
it('is read-only and out of free/busy', () => {
|
||||
const calendar = createCompanyCalendar(FEED);
|
||||
expect(calendar).toMatchObject({ id: '__company-calendar__:hol', name: 'Holidays', color: '#16a34a', isCompany: true, includeInAvailability: 'none' });
|
||||
expect(Object.values(calendar.myRights).filter(Boolean)).toEqual([true, true]); // read free/busy and items only
|
||||
expect(isCompanyCalendarId(calendar.id)).toBe(true);
|
||||
expect(isCompanyCalendarId('b')).toBe(false);
|
||||
});
|
||||
|
||||
it('turns parsed events into events of the calendar, with nothing to answer', () => {
|
||||
const events = toCompanyEvents('hol', [
|
||||
{ ...PARSED[0], participants: { a: { email: 'x@example.org' } } as never, organizerCalendarAddress: 'mailto:x@example.org' },
|
||||
{ uid: 'no-start', title: 'Broken' },
|
||||
]);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ calendarIds: { [companyCalendarId('hol')]: true }, participants: null, organizerCalendarAddress: null, title: 'Closed' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a feed goes away or the user signs out', () => {
|
||||
it('drops the events of a feed the server no longer gives the user, and keeps them through an outage', async () => {
|
||||
const client = fakeClient();
|
||||
await loadFeedEvents(client, 'alice|a', FEED, T0);
|
||||
apiFetch.mockImplementation(async () => new Response('down', { status: 503 }));
|
||||
expect(await loadFeedEvents(client, 'alice|a', FEED, T0 + 2 * HOUR)).toEqual(PARSED);
|
||||
apiFetch.mockImplementation(async () => new Response('{"error":"Unknown calendar"}', { status: 404 }));
|
||||
expect(await loadFeedEvents(client, 'alice|a', FEED, T0 + 3 * HOUR)).toEqual([]);
|
||||
// Nothing kept: the next look asks the server again.
|
||||
await loadFeedEvents(client, 'alice|a', FEED, T0 + 3 * HOUR + 1);
|
||||
expect(apiFetch).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('forgets one login\'s feeds when that account signs out', async () => {
|
||||
const client = fakeClient();
|
||||
await loadFeedEvents(client, 'alice@example.org|a', FEED, T0);
|
||||
await loadFeedEvents(client, 'bob@example.org|b', FEED, T0);
|
||||
useAccountStore.setState({ accounts: [{ id: 'acc-alice', username: 'alice@example.org' }] as never });
|
||||
await runBeforeSignOut('acc-alice');
|
||||
await loadFeedEvents(client, 'alice@example.org|a', FEED, T0 + 60_000);
|
||||
await loadFeedEvents(client, 'bob@example.org|b', FEED, T0 + 60_000);
|
||||
expect(apiFetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Company calendars by id (POST /api/fetch-ical { feedId }): the address
|
||||
// comes from the caller's own scoped policy, their groups read from their
|
||||
// JMAP session on the server, the admin's host may be on the intranet, and
|
||||
// a feed is fetched once per refresh interval for everyone.
|
||||
|
||||
vi.mock('next/server', () => {
|
||||
class NextResponse {
|
||||
status: number;
|
||||
headers: Headers;
|
||||
body: unknown;
|
||||
constructor(body: unknown, init?: { status?: number; headers?: Record<string, string> }) {
|
||||
this.body = body;
|
||||
this.status = init?.status ?? 200;
|
||||
this.headers = new Headers(init?.headers);
|
||||
}
|
||||
static json(data: unknown, init?: { status?: number }) {
|
||||
return Object.assign(new NextResponse(data, init), { json: async () => data });
|
||||
}
|
||||
}
|
||||
return { NextResponse, NextRequest: class {} };
|
||||
});
|
||||
|
||||
const creds = vi.hoisted(() => ({ value: null as null | Record<string, unknown> }));
|
||||
vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: async () => creds.value }));
|
||||
|
||||
const guardedFetch = vi.fn();
|
||||
vi.mock('@/lib/security/url-guard', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/security/url-guard')>()),
|
||||
fetchPublicUrl: (...args: unknown[]) => guardedFetch(...args),
|
||||
}));
|
||||
|
||||
// The caller's JMAP session: alice is in the group ent-team and has bob's
|
||||
// mailbox shared with her (a person, not a group).
|
||||
const session = vi.hoisted(() => ({
|
||||
accounts: {} as Record<string, { name: string; isPersonal: boolean }>,
|
||||
types: {} as Record<string, string>,
|
||||
}));
|
||||
vi.mock('@/lib/stalwart/jmap-api', () => ({
|
||||
fetchJmapSession: async () => ({
|
||||
apiUrl: 'https://mail.example.org/jmap/',
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'alice' },
|
||||
accounts: session.accounts,
|
||||
}),
|
||||
rebaseApiUrl: () => 'https://mail.example.org/jmap/',
|
||||
postJmap: async () => new Response(JSON.stringify({
|
||||
methodResponses: [['Principal/get', { list: Object.entries(session.types).map(([id, type]) => ({ id, type })) }, '0']],
|
||||
})),
|
||||
}));
|
||||
|
||||
const POLICY = {
|
||||
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
|
||||
organisation: { calendars: { feeds: [{ id: 'hol', name: 'Holidays', url: 'https://cal.example.org/h.ics?token=t', color: '#2563eb', refreshMinutes: 60 }] } },
|
||||
scopes: [
|
||||
{ id: 'team', label: 'Team', match: { groups: ['ent-team@example.org'] }, policy: { organisation: { calendars: { feeds: [{ id: 'team', name: 'Team', url: 'http://calendar.corp.internal/team.ics', refreshMinutes: 120 }] } } } },
|
||||
{ id: 'bob', label: 'Bob', match: { groups: ['ent-bob@example.org'] }, policy: { organisation: { calendars: { feeds: [{ id: 'bobs', name: 'Bob', url: 'https://cal.example.org/b.ics' }] } } } },
|
||||
],
|
||||
};
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: { ensureLoaded: async () => {}, getPolicy: () => POLICY },
|
||||
}));
|
||||
|
||||
import { POST } from '@/app/api/fetch-ical/route';
|
||||
import { companyFeedCache } from '@/lib/admin/company-feed-cache';
|
||||
import { clearServerPolicySubjects } from '@/lib/admin/server-policy-subject';
|
||||
|
||||
type RouteResponse = { status: number; body: unknown };
|
||||
const post = async (body: unknown) => (await POST({ method: 'POST', headers: new Headers(), json: async () => body } as never)) as unknown as RouteResponse;
|
||||
const ics = (status = 200, headers: Record<string, string> = {}) => new Response(status === 304 ? null : 'BEGIN:VCALENDAR\nEND:VCALENDAR\n', { status, headers: { 'content-type': 'text/calendar', ...headers } });
|
||||
|
||||
beforeEach(() => {
|
||||
creds.value = { serverUrl: 'https://mail.example.org', authHeader: 'Basic alice', username: 'ent-alice@example.org', accountName: 'ent-alice@example.org', trusted: true };
|
||||
session.accounts = {
|
||||
alice: { name: 'ent-alice@example.org', isPersonal: true },
|
||||
team: { name: 'ent-team@example.org', isPersonal: false },
|
||||
bob: { name: 'ent-bob@example.org', isPersonal: false },
|
||||
};
|
||||
session.types = { team: 'group', bob: 'individual' };
|
||||
guardedFetch.mockReset();
|
||||
companyFeedCache.clear();
|
||||
clearServerPolicySubjects();
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('POST /api/fetch-ical { feedId }', () => {
|
||||
it('fetches a feed of Everyone and one of a group the session shows the user is in, from the policy on the server', async () => {
|
||||
guardedFetch.mockImplementation(async () => ics());
|
||||
expect((await post({ feedId: 'hol' })).status).toBe(200);
|
||||
expect(guardedFetch.mock.calls[0][0]).toBe('https://cal.example.org/h.ics?token=t');
|
||||
expect((await post({ feedId: 'team' })).status).toBe(200);
|
||||
// The admin's own intranet host may be private, and only it; a public
|
||||
// host gets no exception.
|
||||
expect(guardedFetch.mock.calls[0]).toHaveLength(2);
|
||||
expect(guardedFetch.mock.calls[1][0]).toBe('http://calendar.corp.internal/team.ics');
|
||||
expect(guardedFetch.mock.calls[1][2]).toEqual({ allowPrivateHost: 'calendar.corp.internal' });
|
||||
});
|
||||
|
||||
it('refuses a feed of a scope the user is not in, without fetching anything', async () => {
|
||||
session.accounts = { alice: { name: 'ent-alice@example.org', isPersonal: true } };
|
||||
expect((await post({ feedId: 'team' })).status).toBe(404);
|
||||
// A person's shared mailbox is no group, whatever it is called.
|
||||
expect((await post({ feedId: 'bobs' })).status).toBe(404);
|
||||
expect((await post({ feedId: 'nope' })).status).toBe(404);
|
||||
expect((await post({ feedId: 42 })).status).toBe(404);
|
||||
expect(guardedFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers from what it fetched within the refresh interval, and asks again with the validators after it', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date('2026-11-02T08:00:00Z'));
|
||||
guardedFetch.mockResolvedValueOnce(ics(200, { etag: '"v1"', 'last-modified': 'Mon, 02 Nov 2026 07:00:00 GMT' }));
|
||||
await post({ feedId: 'hol' });
|
||||
await post({ feedId: 'hol' });
|
||||
expect(guardedFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.setSystemTime(new Date('2026-11-02T09:01:00Z'));
|
||||
guardedFetch.mockResolvedValueOnce(ics(304));
|
||||
const res = await post({ feedId: 'hol' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(Buffer.from(res.body as ArrayBuffer).toString()).toContain('BEGIN:VCALENDAR');
|
||||
expect(guardedFetch).toHaveBeenCalledTimes(2);
|
||||
expect(guardedFetch.mock.calls[1][1].headers).toMatchObject({ 'If-None-Match': '"v1"', 'If-Modified-Since': 'Mon, 02 Nov 2026 07:00:00 GMT' });
|
||||
});
|
||||
|
||||
it('keeps the intranet exception off a redirect to another port', async () => {
|
||||
guardedFetch
|
||||
.mockImplementationOnce(async () => new Response(null, { status: 302, headers: { location: 'http://calendar.corp.internal:8443/x' } }))
|
||||
.mockImplementationOnce(async () => ics());
|
||||
expect((await post({ feedId: 'team' })).status).toBe(200);
|
||||
expect(guardedFetch.mock.calls[0][2]).toEqual({ allowPrivateHost: 'calendar.corp.internal' });
|
||||
expect(guardedFetch.mock.calls[1]).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('refuses an answer that is not a calendar', async () => {
|
||||
guardedFetch.mockImplementation(async () => new Response('{"secret":1}', { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
expect((await post({ feedId: 'team' })).status).toBe(502);
|
||||
});
|
||||
|
||||
it('fetches a feed once for requests that arrive together', async () => {
|
||||
let release: () => void = () => {};
|
||||
guardedFetch.mockImplementation(() => new Promise((resolve) => { release = () => resolve(ics()); }));
|
||||
const both = Promise.all([post({ feedId: 'hol' }), post({ feedId: 'hol' })]);
|
||||
await vi.waitFor(() => expect(guardedFetch).toHaveBeenCalledTimes(1));
|
||||
release();
|
||||
expect((await both).map((r) => r.status)).toEqual([200, 200]);
|
||||
expect(guardedFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('answers a feed that just failed with its error for a minute without asking again', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date('2026-11-02T08:00:00Z'));
|
||||
guardedFetch.mockImplementation(async () => new Response('down', { status: 500 }));
|
||||
expect((await post({ feedId: 'hol' })).status).toBe(502);
|
||||
expect((await post({ feedId: 'hol' })).status).toBe(502);
|
||||
expect(guardedFetch).toHaveBeenCalledTimes(1);
|
||||
vi.setSystemTime(new Date('2026-11-02T08:01:01Z'));
|
||||
guardedFetch.mockImplementation(async () => ics());
|
||||
expect((await post({ feedId: 'hol' })).status).toBe(200);
|
||||
expect(guardedFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('needs a signed-in user', async () => {
|
||||
creds.value = null;
|
||||
expect((await post({ feedId: 'hol' })).status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { findFreeSlots, searchHours, searchWindow } from '../find-a-time';
|
||||
import type { BusyPeriod } from '@/lib/jmap/types';
|
||||
|
||||
const busy = (utcStart: string, utcEnd: string, busyStatus: BusyPeriod['busyStatus'] = 'confirmed'): BusyPeriod => ({ utcStart, utcEnd, busyStatus });
|
||||
const HOUR = 3600_000;
|
||||
|
||||
// Monday 2 November 2026, 07:00 in Berlin (06:00 UTC, winter time).
|
||||
const monday = new Date('2026-11-02T06:00:00Z');
|
||||
const base = {
|
||||
now: monday,
|
||||
durationMs: HOUR,
|
||||
timeZone: 'Europe/Berlin',
|
||||
workingDays: [1, 2, 3, 4, 5],
|
||||
dayStartHour: 9,
|
||||
dayEndHour: 17,
|
||||
busy: [] as BusyPeriod[],
|
||||
};
|
||||
const starts = (slots: { start: Date }[]) => slots.map((s) => s.start.toISOString());
|
||||
|
||||
describe('findFreeSlots', () => {
|
||||
it('starts at the first working hour in the effective zone, on a 30-minute grid', () => {
|
||||
expect(starts(findFreeSlots(base))).toEqual([
|
||||
'2026-11-02T08:00:00.000Z', // 09:00 Berlin
|
||||
'2026-11-02T08:30:00.000Z',
|
||||
'2026-11-02T09:00:00.000Z',
|
||||
'2026-11-02T09:30:00.000Z',
|
||||
'2026-11-02T10:00:00.000Z',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads the hours in the given zone, not the browser zone', () => {
|
||||
const slots = findFreeSlots({ ...base, timeZone: 'America/New_York', now: new Date('2026-11-02T00:00:00Z'), maxSlots: 1 });
|
||||
// 09:00 in New York on Monday 2 November (EST, UTC-5).
|
||||
expect(starts(slots)).toEqual(['2026-11-02T14:00:00.000Z']);
|
||||
});
|
||||
|
||||
it('skips busy and tentative time of everyone and keeps slots inside the day', () => {
|
||||
const slots = findFreeSlots({
|
||||
...base,
|
||||
busy: [
|
||||
busy('2026-11-02T08:00:00Z', '2026-11-02T10:00:00Z'), // Bob 9-11 Berlin
|
||||
busy('2026-11-02T10:00:00Z', '2026-11-02T11:00:00Z', 'tentative'), // Carol 11-12
|
||||
],
|
||||
maxSlots: 2,
|
||||
});
|
||||
expect(starts(slots)).toEqual(['2026-11-02T11:00:00.000Z', '2026-11-02T11:30:00.000Z']);
|
||||
});
|
||||
|
||||
it('never ends a slot after the day ends and moves to the next working day', () => {
|
||||
const lateMonday = new Date('2026-11-02T15:10:00Z'); // 16:10 Berlin
|
||||
const slots = findFreeSlots({ ...base, now: lateMonday, maxSlots: 1 });
|
||||
// 16:30-17:30 would end after 17:00; Tuesday 09:00 is next.
|
||||
expect(starts(slots)).toEqual(['2026-11-03T08:00:00.000Z']);
|
||||
});
|
||||
|
||||
it('only uses the working days', () => {
|
||||
const friday = new Date('2026-11-06T16:30:00Z'); // 17:30 Berlin, after hours
|
||||
const slots = findFreeSlots({ ...base, now: friday, maxSlots: 1 });
|
||||
expect(starts(slots)).toEqual(['2026-11-09T08:00:00.000Z']); // Monday
|
||||
const sundayOnly = findFreeSlots({ ...base, workingDays: [0], maxSlots: 1 });
|
||||
expect(starts(sundayOnly)).toEqual(['2026-11-08T08:00:00.000Z']);
|
||||
});
|
||||
|
||||
it('falls back to Monday to Friday and 8 to 20 for unusable settings', () => {
|
||||
const slots = findFreeSlots({ ...base, workingDays: [], dayStartHour: 20, dayEndHour: 8, maxSlots: 1 });
|
||||
expect(starts(slots)).toEqual(['2026-11-02T07:00:00.000Z']); // 08:00 Berlin
|
||||
});
|
||||
|
||||
it('finds nothing when a meeting is longer than the working day', () => {
|
||||
expect(findFreeSlots({ ...base, durationMs: 9 * HOUR })).toEqual([]);
|
||||
});
|
||||
|
||||
it('finds nothing when everything in the next ten working days is busy', () => {
|
||||
const slots = findFreeSlots({ ...base, busy: [busy('2026-11-01T00:00:00Z', '2026-11-30T00:00:00Z')] });
|
||||
expect(slots).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchWindow', () => {
|
||||
it('runs from now to the end of the tenth working day', () => {
|
||||
const window = searchWindow(base);
|
||||
expect(window.start).toBe(monday);
|
||||
// Ten working days from Monday 2 November: the last is Friday 13 November, ending 17:00 Berlin.
|
||||
expect(window.end.toISOString()).toBe('2026-11-13T16:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchHours', () => {
|
||||
it('keeps a whole day the user set on purpose', () => {
|
||||
expect(searchHours(0, 24)).toEqual({ startMinutes: 0, endMinutes: 1440 });
|
||||
expect(searchHours(7, 19)).toEqual({ startMinutes: 420, endMinutes: 1140 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
applyAdminFreeBusyShare, ensureFreeBusyShare, freeBusyShareGroups, groupPrincipalId, resetAppliedFreeBusyShares,
|
||||
} from '../free-busy-share';
|
||||
import { CALENDAR_PRESETS } from '@/lib/jmap/share-rights';
|
||||
import type { Calendar, CalendarRights } from '@/lib/jmap/types';
|
||||
|
||||
const READ = CALENDAR_PRESETS.read;
|
||||
const CAPS = { mail: true, sieve: true, calendars: true, contacts: true, filenode: true };
|
||||
const NO_FREE_BUSY: CalendarRights = { ...CALENDAR_PRESETS.read, mayReadFreeBusy: false, mayReadItems: false, mayRSVP: true };
|
||||
|
||||
function calendar(id: string, extra: Partial<Calendar> = {}): Calendar {
|
||||
return { id, name: id, isDefault: false, shareWith: null, ...extra } as Calendar;
|
||||
}
|
||||
|
||||
function fakeClient(calendars: Calendar[], options: { groupAccount?: boolean; directory?: string | null } = {}) {
|
||||
const setCalendarShare = vi.fn(async () => {});
|
||||
const client = {
|
||||
getAccountId: () => 'alice',
|
||||
getSharedAccounts: () => [
|
||||
{ id: 'alice', name: 'alice@example.org', isPrimary: true, capabilities: CAPS },
|
||||
...(options.groupAccount === false ? [] : [{ id: 'team', name: 'Team@example.org', isPrimary: false, capabilities: CAPS }]),
|
||||
{ id: 'bob', name: 'bob@example.org', isPrimary: false, capabilities: CAPS },
|
||||
],
|
||||
getAccountInfo: (id: string) => ({ name: id, isPersonal: id === 'alice' || id === 'bob', isReadOnly: false, accountCapabilities: {} }),
|
||||
findPrincipalByEmail: vi.fn(async () => ({
|
||||
principal: options.directory ? { id: options.directory, type: 'group' as const, name: 'staff' } : null,
|
||||
status: 'ok' as const,
|
||||
})),
|
||||
getAllCalendars: vi.fn(async () => calendars),
|
||||
setCalendarShare,
|
||||
};
|
||||
return { client, setCalendarShare };
|
||||
}
|
||||
|
||||
describe('ensureFreeBusyShare', () => {
|
||||
it('shares only free/busy of the default calendar with the group, leaving other shares alone', async () => {
|
||||
const { client, setCalendarShare } = fakeClient([
|
||||
calendar('b', { isDefault: false }),
|
||||
calendar('a', { isDefault: true, shareWith: { bob: READ } }),
|
||||
calendar('team:x', { isDefault: true, isShared: true }),
|
||||
]);
|
||||
expect(await ensureFreeBusyShare(client, 'team@example.org')).toBe('shared');
|
||||
// One shareWith/<id> patch: the share with bob isn't rewritten.
|
||||
expect(setCalendarShare).toHaveBeenCalledTimes(1);
|
||||
expect(setCalendarShare).toHaveBeenCalledWith('a', 'team', CALENDAR_PRESETS.freeBusy);
|
||||
});
|
||||
|
||||
it('writes nothing when the group can see the free/busy already', async () => {
|
||||
const { client, setCalendarShare } = fakeClient([calendar('a', { isDefault: true, shareWith: { team: READ } })]);
|
||||
expect(await ensureFreeBusyShare(client, 'team@example.org')).toBe('already');
|
||||
expect(setCalendarShare).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds free/busy to a share of the group that lacked it, keeping its other rights', async () => {
|
||||
const { client, setCalendarShare } = fakeClient([calendar('a', { isDefault: true, shareWith: { team: NO_FREE_BUSY } })]);
|
||||
expect(await ensureFreeBusyShare(client, 'team@example.org')).toBe('shared');
|
||||
expect(setCalendarShare).toHaveBeenCalledWith('a', 'team', { ...NO_FREE_BUSY, mayReadFreeBusy: true });
|
||||
});
|
||||
|
||||
it('looks the group up in the directory when it is no account of the user', async () => {
|
||||
const { client, setCalendarShare } = fakeClient([calendar('a', { isDefault: true })], { directory: 'staff' });
|
||||
expect(await ensureFreeBusyShare(client, 'staff@example.org')).toBe('shared');
|
||||
expect(setCalendarShare).toHaveBeenCalledWith('a', 'staff', CALENDAR_PRESETS.freeBusy);
|
||||
});
|
||||
|
||||
it('does nothing for an unknown group or without a calendar of the user', async () => {
|
||||
const unknown = fakeClient([calendar('a', { isDefault: true })], { directory: null });
|
||||
expect(await ensureFreeBusyShare(unknown.client, 'nobody@example.org')).toBe('no-group');
|
||||
const none = fakeClient([calendar('team:x', { isShared: true })]);
|
||||
expect(await ensureFreeBusyShare(none.client, 'team@example.org')).toBe('no-calendar');
|
||||
expect(unknown.setCalendarShare).not.toHaveBeenCalled();
|
||||
expect(none.setCalendarShare).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never shares with a directory entry that is no group', async () => {
|
||||
const { client, setCalendarShare } = fakeClient([calendar('a', { isDefault: true })], { groupAccount: false });
|
||||
client.findPrincipalByEmail.mockResolvedValue({ principal: { id: 'p', name: 'x' } as never, status: 'ok' });
|
||||
expect(await ensureFreeBusyShare(client, 'person@example.org')).toBe('no-group');
|
||||
client.findPrincipalByEmail.mockResolvedValue({ principal: { id: 'p', type: 'individual', name: 'x' } as never, status: 'ok' });
|
||||
expect(await ensureFreeBusyShare(client, 'person@example.org')).toBe('no-group');
|
||||
expect(setCalendarShare).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never takes a person\'s shared account for the group', async () => {
|
||||
const { client } = fakeClient([], { groupAccount: false, directory: null });
|
||||
expect(await groupPrincipalId(client, 'bob@example.org')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyAdminFreeBusyShare', () => {
|
||||
beforeEach(() => resetAppliedFreeBusyShares());
|
||||
|
||||
it('applies once per page load and account, and again after a failure', async () => {
|
||||
const { client, setCalendarShare } = fakeClient([calendar('a', { isDefault: true })]);
|
||||
setCalendarShare.mockRejectedValueOnce(new Error('down'));
|
||||
expect(await applyAdminFreeBusyShare(client, 'team@example.org')).toBeNull();
|
||||
expect(await applyAdminFreeBusyShare(client, 'team@example.org')).toBe('shared');
|
||||
expect(await applyAdminFreeBusyShare(client, 'team@example.org')).toBeNull();
|
||||
expect(setCalendarShare).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does nothing without a group', async () => {
|
||||
const { client, setCalendarShare } = fakeClient([calendar('a', { isDefault: true })]);
|
||||
expect(await applyAdminFreeBusyShare(client, '')).toBeNull();
|
||||
expect(setCalendarShare).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('freeBusyShareGroups', () => {
|
||||
it('offers the admin group, else the group accounts of the session', () => {
|
||||
const { client } = fakeClient([]);
|
||||
expect(freeBusyShareGroups(client, 'Staff@example.org')).toEqual(['staff@example.org']);
|
||||
expect(freeBusyShareGroups(client, '')).toEqual(['team@example.org']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { purgeSignedOutData } from '../sign-out-cleanup';
|
||||
|
||||
// The company calendar cache (lib/company-calendars.ts) goes on a full
|
||||
// sign-out even in a tab that never loaded the calendar: this file doesn't
|
||||
// import it.
|
||||
|
||||
describe('purgeSignedOutData', () => {
|
||||
it('deletes the stored company calendars by name', () => {
|
||||
const deleteDatabase = vi.fn();
|
||||
Object.defineProperty(window, 'indexedDB', { value: { deleteDatabase }, configurable: true });
|
||||
try {
|
||||
purgeSignedOutData();
|
||||
expect(deleteDatabase).toHaveBeenCalledWith('bulwark-company-calendars');
|
||||
} finally {
|
||||
delete (window as unknown as Record<string, unknown>).indexedDB;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment node
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
|
||||
// A company calendar may live on the intranet: fetchPublicUrl lets the one
|
||||
// host the admin entered resolve to an intranet address, and only when that
|
||||
// host itself names the intranet (an intranet address, .local/.internal, a
|
||||
// single-label name). Loopback, link-local and metadata addresses stay
|
||||
// refused for it, a public name gets no exception at all, and the address is
|
||||
// still pinned at connect time.
|
||||
|
||||
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('what counts as the intranet', () => {
|
||||
it('takes intranet addresses and names, never loopback, link-local, metadata or public names', async () => {
|
||||
const { isIntranetAddress, isIntranetHostName } = await import('@/lib/security/url-guard');
|
||||
for (const ip of ['10.1.2.3', '172.16.0.9', '192.168.178.20', '100.64.1.1', 'fd12:3456::1', '::ffff:10.0.0.1']) {
|
||||
expect(isIntranetAddress(ip), ip).toBe(true);
|
||||
}
|
||||
for (const ip of ['127.0.0.1', '169.254.169.254', '0.0.0.0', '::1', 'fe80::1', 'fd00:ec2::254', '::ffff:127.0.0.1', '8.8.8.8', '224.0.0.1']) {
|
||||
expect(isIntranetAddress(ip), ip).toBe(false);
|
||||
}
|
||||
for (const host of ['calendar', 'cal.corp.internal', 'nas.local', 'srv.localdomain', '10.0.0.5', '[fd12::1]']) {
|
||||
expect(isIntranetHostName(host), host).toBe(true);
|
||||
}
|
||||
for (const host of ['cal.example.org', 'localhost', 'app.localhost', '127.0.0.1', '169.254.169.254', '']) {
|
||||
expect(isIntranetHostName(host), host).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchPublicUrl with allowPrivateHost', () => {
|
||||
let server: Server;
|
||||
let port: number;
|
||||
let hits = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createServer((_req, res) => {
|
||||
hits++;
|
||||
res.writeHead(200, { 'content-type': 'text/calendar' });
|
||||
res.end('BEGIN:VCALENDAR\nEND:VCALENDAR\n');
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
port = (server.address() as AddressInfo).port;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
beforeEach(() => {
|
||||
lookup.mockReset();
|
||||
hits = 0;
|
||||
});
|
||||
|
||||
it('lets an intranet name through to an intranet address', async () => {
|
||||
lookup.mockResolvedValue([{ address: '10.255.255.1', family: 4 }]);
|
||||
const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard');
|
||||
// Nothing answers there: the connection is tried (the guard lets it
|
||||
// through) and runs out of time.
|
||||
const error = await fetchPublicUrl('http://calendar:8080/team.ics', { signal: AbortSignal.timeout(300) }, { allowPrivateHost: 'calendar' })
|
||||
.catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).not.toBeInstanceOf(DisallowedUrlError);
|
||||
expect(lookup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses loopback and metadata addresses even for the admin\'s intranet host', async () => {
|
||||
const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard');
|
||||
lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
|
||||
await expect(fetchPublicUrl(`http://calendar:${port}/team.ics`, {}, { allowPrivateHost: 'calendar' }))
|
||||
.rejects.toBeInstanceOf(DisallowedUrlError);
|
||||
lookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]);
|
||||
await expect(fetchPublicUrl('http://cal.corp.internal/latest/meta-data/', {}, { allowPrivateHost: 'cal.corp.internal' }))
|
||||
.rejects.toBeInstanceOf(DisallowedUrlError);
|
||||
await expect(fetchPublicUrl(`http://127.0.0.1:${port}/team.ics`, {}, { allowPrivateHost: '127.0.0.1' }))
|
||||
.rejects.toBeInstanceOf(DisallowedUrlError);
|
||||
expect(hits).toBe(0);
|
||||
});
|
||||
|
||||
it('gives a public host no exception, whatever its DNS points at', async () => {
|
||||
const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard');
|
||||
lookup.mockResolvedValue([{ address: '10.0.0.7', family: 4 }]);
|
||||
await expect(fetchPublicUrl('http://partner.example.org/cal.ics', {}, { allowPrivateHost: 'partner.example.org' }))
|
||||
.rejects.toBeInstanceOf(DisallowedUrlError);
|
||||
// Another intranet name than the admin's stays refused.
|
||||
await expect(fetchPublicUrl('http://other.corp.internal/a.ics', {}, { allowPrivateHost: 'cal.corp.internal' }))
|
||||
.rejects.toBeInstanceOf(DisallowedUrlError);
|
||||
expect(hits).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
blockTimes, decideBlock, ownFilterScriptStops, resolveBlockSetting, effectiveBlockSetting, eventSpan, isoDuration, oooUid, resetVacationCalendarSync,
|
||||
syncVacationBlock, vacationReplyLoaded, vacationReplySaved, type DecisionInput,
|
||||
} from '../vacation-calendar';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
import { generateScript } from '@/lib/sieve/generator';
|
||||
|
||||
const NOW = new Date('2026-11-10T12:00:00Z');
|
||||
const reply = (extra: Partial<DecisionInput['reply']> = {}) => ({
|
||||
isEnabled: true, fromDate: '2026-12-01T08:00:00Z', toDate: '2026-12-05T17:00:00Z', subject: 'Away', ...extra,
|
||||
});
|
||||
const span = (start: string, end: string) => ({ start: new Date(start), end: new Date(end) });
|
||||
const last = { start: '2026-12-01T08:00:00.000Z', end: '2026-12-05T17:00:00.000Z' };
|
||||
const input = (extra: Partial<DecisionInput>): DecisionInput => ({
|
||||
block: true, reply: reply(), event: null, last: null, now: NOW, trigger: 'save', ...extra,
|
||||
});
|
||||
|
||||
describe('decideBlock', () => {
|
||||
it('creates the event for a reply with dates', () => {
|
||||
expect(decideBlock(input({}))).toEqual({ action: 'create', start: new Date('2026-12-01T08:00:00Z'), end: new Date('2026-12-05T17:00:00Z') });
|
||||
});
|
||||
|
||||
it('moves its own event with the dates', () => {
|
||||
const decision = decideBlock(input({ reply: reply({ toDate: '2026-12-08T17:00:00Z' }), event: span(last.start, last.end), last }));
|
||||
expect(decision).toEqual({ action: 'update', start: new Date('2026-12-01T08:00:00Z'), end: new Date('2026-12-08T17:00:00Z') });
|
||||
});
|
||||
|
||||
it('leaves an event someone edited alone', () => {
|
||||
const edited = span('2026-12-01T08:00:00Z', '2026-12-03T12:00:00Z');
|
||||
expect(decideBlock(input({ reply: reply({ toDate: '2026-12-08T17:00:00Z' }), event: edited, last }))).toEqual({ action: 'none' });
|
||||
expect(decideBlock(input({ reply: reply({ isEnabled: false }), event: edited, last }))).toEqual({ action: 'none' });
|
||||
});
|
||||
|
||||
it('removes an event that hasn\'t started when the reply or the block is switched off', () => {
|
||||
expect(decideBlock(input({ reply: reply({ isEnabled: false }), event: span(last.start, last.end), last }))).toEqual({ action: 'destroy' });
|
||||
expect(decideBlock(input({ block: false, event: span(last.start, last.end), last }))).toEqual({ action: 'destroy' });
|
||||
});
|
||||
|
||||
it('ends a running event now when the reply is switched off early', () => {
|
||||
const now = new Date('2026-12-03T10:00:00Z');
|
||||
expect(decideBlock(input({ reply: reply({ isEnabled: false }), event: span(last.start, last.end), last, now }))).toEqual({ action: 'end-now', end: now });
|
||||
});
|
||||
|
||||
it('leaves the past event of a reply that simply ran out', () => {
|
||||
const now = new Date('2026-12-06T10:00:00Z');
|
||||
expect(decideBlock(input({ event: span(last.start, last.end), last, now }))).toEqual({ action: 'none' });
|
||||
expect(decideBlock(input({ now }))).toEqual({ action: 'none' });
|
||||
});
|
||||
|
||||
it('keeps a deleted event deleted on load, and brings it back when the dates change', () => {
|
||||
expect(decideBlock(input({ last, trigger: 'load' }))).toEqual({ action: 'none' });
|
||||
expect(decideBlock(input({ last, trigger: 'load', reply: reply({ toDate: '2026-12-09T17:00:00Z' }) })).action).toBe('create');
|
||||
});
|
||||
|
||||
it('follows a reply set in another client on load', () => {
|
||||
expect(decideBlock(input({ trigger: 'load' })).action).toBe('create');
|
||||
});
|
||||
|
||||
it('does nothing without both dates or with the end before the start', () => {
|
||||
expect(decideBlock(input({ reply: reply({ toDate: null }) }))).toEqual({ action: 'none' });
|
||||
expect(decideBlock(input({ reply: reply({ toDate: '2026-11-30T00:00:00Z' }) }))).toEqual({ action: 'none' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('blockTimes and eventSpan', () => {
|
||||
it('writes a timed event in the zone and reads it back', () => {
|
||||
const start = new Date('2026-12-01T08:00:00Z');
|
||||
const end = new Date('2026-12-05T17:30:00Z');
|
||||
const times = blockTimes(start, end, 'Europe/Berlin');
|
||||
expect(times).toEqual({ start: '2026-12-01T09:00:00', duration: 'P4DT9H30M', timeZone: 'Europe/Berlin', showWithoutTime: false });
|
||||
expect(eventSpan(times, 'Europe/Berlin')).toEqual({ start, end });
|
||||
});
|
||||
|
||||
it('writes whole days when both ends are midnight in the zone', () => {
|
||||
const start = new Date('2026-11-30T23:00:00Z'); // 1 Dec 00:00 Berlin
|
||||
const end = new Date('2026-12-04T23:00:00Z'); // 5 Dec 00:00 Berlin
|
||||
const times = blockTimes(start, end, 'Europe/Berlin');
|
||||
expect(times).toEqual({ start: '2026-12-01T00:00:00', duration: 'P4D', timeZone: null, showWithoutTime: true });
|
||||
expect(eventSpan(times, 'Europe/Berlin')).toEqual({ start, end });
|
||||
});
|
||||
|
||||
it('formats durations', () => {
|
||||
expect(isoDuration(90 * 60000)).toBe('PT1H30M');
|
||||
expect(isoDuration(2 * 86400000)).toBe('P2D');
|
||||
expect(isoDuration(0)).toBe('PT0S');
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectiveBlockSetting', () => {
|
||||
afterEach(() => {
|
||||
usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy, restrictions: {} } });
|
||||
useSettingsStore.setState({ vacationBlockCalendar: true });
|
||||
});
|
||||
|
||||
it('is on by default, the user\'s choice otherwise, and the organisation\'s when locked', () => {
|
||||
expect(effectiveBlockSetting()).toBe(true);
|
||||
useSettingsStore.setState({ vacationBlockCalendar: false });
|
||||
expect(effectiveBlockSetting()).toBe(false);
|
||||
usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy, restrictions: { vacationBlockCalendar: { locked: true, value: true } } } });
|
||||
expect(effectiveBlockSetting()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function fakeClient(found: Partial<CalendarEvent> | null, calendars: Partial<Calendar>[] = [{ id: 'b', isDefault: true, myRights: { mayWriteAll: true } as Calendar['myRights'] }]) {
|
||||
return {
|
||||
getAccountId: () => 'alice',
|
||||
supportsCalendars: () => true,
|
||||
getAllCalendars: vi.fn(async () => calendars as Calendar[]),
|
||||
queryCalendarEvents: vi.fn(async () => (found ? [found as CalendarEvent] : [])),
|
||||
createCalendarEvent: vi.fn(async (e: Partial<CalendarEvent>) => ({ ...e, id: 'new' }) as CalendarEvent),
|
||||
updateCalendarEvent: vi.fn(async () => {}),
|
||||
deleteCalendarEvent: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('syncVacationBlock', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ vacationBlockCalendar: true, vacationCalendarBlocks: {}, timeZone: 'Europe/Berlin' });
|
||||
});
|
||||
afterEach(() => useSettingsStore.setState({ timeZone: 'auto' }));
|
||||
|
||||
it('creates a busy event without participants or invitations in the default calendar, and remembers it', async () => {
|
||||
const client = fakeClient(null, [
|
||||
{ id: 'x', isDefault: false },
|
||||
{ id: 'b', isDefault: true, myRights: { mayWriteAll: true } as Calendar['myRights'] },
|
||||
{ id: 'bob:c', isDefault: true, isShared: true, accountId: 'bob' },
|
||||
]);
|
||||
expect(await syncVacationBlock(client, undefined, reply(), 'save', NOW)).toBe('create');
|
||||
expect(client.queryCalendarEvents).toHaveBeenCalledWith({ uid: 'bulwark-ooo-alice' }, undefined, 1, undefined);
|
||||
const [event, send, account] = client.createCalendarEvent.mock.calls[0] as unknown as [Partial<CalendarEvent>, boolean, string | undefined];
|
||||
expect(event).toMatchObject({
|
||||
uid: oooUid('alice'), title: 'Away', calendarIds: { b: true }, freeBusyStatus: 'busy', privacy: 'public',
|
||||
start: '2026-12-01T09:00:00', timeZone: 'Europe/Berlin', showWithoutTime: false,
|
||||
});
|
||||
expect(event.participants).toBeUndefined();
|
||||
expect(send).toBe(false);
|
||||
expect(account).toBeUndefined();
|
||||
expect(useSettingsStore.getState().vacationCalendarBlocks.alice).toEqual(last);
|
||||
});
|
||||
|
||||
it('puts a managed account\'s event in that account\'s calendar, and only with write access', async () => {
|
||||
const shared = { id: 'boss:b', originalId: 'b', isDefault: true, isShared: true, accountId: 'boss', myRights: { mayWriteAll: false, mayWriteOwn: true } as Calendar['myRights'] };
|
||||
const client = fakeClient(null, [{ id: 'own', isDefault: true }, shared]);
|
||||
expect(await syncVacationBlock(client, 'boss', reply(), 'save', NOW)).toBe('create');
|
||||
expect(client.createCalendarEvent.mock.calls[0][0]).toMatchObject({ uid: 'bulwark-ooo-boss', calendarIds: { b: true } });
|
||||
expect((client.createCalendarEvent.mock.calls[0] as unknown[])[2]).toBe('boss');
|
||||
const readOnly = fakeClient(null, [{ ...shared, myRights: { mayWriteAll: false, mayWriteOwn: false } as Calendar['myRights'] }]);
|
||||
expect(await syncVacationBlock(readOnly, 'boss', reply(), 'save', NOW)).toBe('no-calendar');
|
||||
expect(readOnly.createCalendarEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moves and removes its own event', async () => {
|
||||
useSettingsStore.setState({ vacationCalendarBlocks: { alice: last } });
|
||||
const ours = { id: 'e1', ...blockTimes(new Date(last.start), new Date(last.end), 'Europe/Berlin') };
|
||||
const moving = fakeClient(ours);
|
||||
expect(await syncVacationBlock(moving, undefined, reply({ toDate: '2026-12-08T17:00:00Z' }), 'save', NOW)).toBe('update');
|
||||
expect(moving.updateCalendarEvent).toHaveBeenCalledWith('e1', expect.objectContaining({ duration: 'P7DT9H' }), false, undefined);
|
||||
useSettingsStore.setState({ vacationCalendarBlocks: { alice: last } });
|
||||
const off = fakeClient(ours);
|
||||
expect(await syncVacationBlock(off, undefined, reply({ isEnabled: false }), 'save', NOW)).toBe('destroy');
|
||||
expect(off.deleteCalendarEvent).toHaveBeenCalledWith('e1', false, undefined);
|
||||
expect(useSettingsStore.getState().vacationCalendarBlocks.alice).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never creates an event while the organisation locks the block off', async () => {
|
||||
usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy, restrictions: { vacationBlockCalendar: { locked: true, value: false } } } });
|
||||
try {
|
||||
const client = fakeClient(null);
|
||||
expect(await syncVacationBlock(client, undefined, reply(), 'save', NOW)).toBe('none');
|
||||
expect(client.createCalendarEvent).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy, restrictions: {} } });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the sync scheduler', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
resetVacationCalendarSync();
|
||||
useSettingsStore.setState({ vacationBlockCalendar: true, vacationCalendarBlocks: {} });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
resetVacationCalendarSync();
|
||||
});
|
||||
|
||||
it('syncs only when the switch, the dates or the subject changed, once after a pause', async () => {
|
||||
const client = fakeClient(null);
|
||||
vacationReplySaved(client, undefined, reply(), reply());
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(client.queryCalendarEvents).not.toHaveBeenCalled();
|
||||
|
||||
vacationReplySaved(client, undefined, reply(), reply({ subject: 'A' }));
|
||||
vacationReplySaved(client, undefined, reply({ subject: 'A' }), reply({ subject: 'Away from' }));
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(client.queryCalendarEvents).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reconciles a loaded reply once per page load and account', async () => {
|
||||
const client = fakeClient(null);
|
||||
vacationReplyLoaded(client, undefined, reply());
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
vacationReplyLoaded(client, undefined, reply());
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(client.queryCalendarEvents).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ownFilterScriptStops', () => {
|
||||
const sieve = (scripts: Array<{ name: string; isActive: boolean; content: string }>, extensions = ['include', 'fileinto']) => ({
|
||||
getSieveAccountId: () => 'alice',
|
||||
getSieveCapabilities: () => ({ sieveExtensions: extensions }) as never,
|
||||
getSieveScripts: async () => scripts.map((s, i) => ({ id: String(i), name: s.name, isActive: s.isActive, blobId: s.name })) as never,
|
||||
getSieveScriptContent: async (blobId: string) => scripts.find((s) => s.name === blobId)!.content,
|
||||
});
|
||||
|
||||
it('warns for an active script Bulwark can\'t keep running, and not for its own', async () => {
|
||||
const own = generateScript([]);
|
||||
// Edited by hand so that Bulwark can't read it back: it can't be kept next to the reply.
|
||||
const handEdited = own.replace('"version"', '"version" oops');
|
||||
expect(handEdited).not.toBe(own);
|
||||
expect(await ownFilterScriptStops(sieve([{ name: 'mine', isActive: true, content: handEdited }]))).toBe(true);
|
||||
// Scripts of other clients that Bulwark reads keep running next to the reply.
|
||||
expect(await ownFilterScriptStops(sieve([{ name: 'roundcube', isActive: true, content: 'require ["fileinto"];\r\nif address :is "from" "x@example.org" { fileinto "X"; }\r\n' }]))).toBe(false);
|
||||
expect(await ownFilterScriptStops(sieve([{ name: 'filters', isActive: true, content: own }]))).toBe(false);
|
||||
expect(await ownFilterScriptStops(sieve([{ name: 'filters', isActive: true, content: own }], ['fileinto']))).toBe(true);
|
||||
expect(await ownFilterScriptStops(sieve([{ name: 'vacation', isActive: true, content: '' }]))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('whose event it is without this device\'s record', () => {
|
||||
it('follows the reply\'s previous dates: switched off or moved on another device or by a delegate', () => {
|
||||
const event = span(last.start, last.end);
|
||||
const previous = { fromDate: '2026-12-01T08:00:00Z', toDate: '2026-12-05T17:00:00Z' };
|
||||
expect(decideBlock(input({ reply: reply({ isEnabled: false }), event, previous }))).toEqual({ action: 'destroy' });
|
||||
expect(decideBlock(input({ reply: reply({ toDate: '2026-12-08T17:00:00Z' }), event, previous })).action).toBe('update');
|
||||
// Someone moved the event before: neither the record nor the old dates match.
|
||||
expect(decideBlock(input({ reply: reply({ isEnabled: false }), event: span('2026-12-02T08:00:00Z', '2026-12-03T08:00:00Z'), previous })))
|
||||
.toEqual({ action: 'none' });
|
||||
});
|
||||
|
||||
it('takes a marked event as its own when the reply is loaded on a device with no record', () => {
|
||||
const event = span(last.start, last.end);
|
||||
expect(decideBlock(input({ reply: reply({ toDate: '2026-12-08T17:00:00Z' }), event, marked: true, trigger: 'load' })).action).toBe('update');
|
||||
expect(decideBlock(input({ reply: reply({ toDate: '2026-12-08T17:00:00Z' }), event, marked: false, trigger: 'load' }))).toEqual({ action: 'none' });
|
||||
expect(decideBlock(input({ reply: reply({ toDate: '2026-12-08T17:00:00Z' }), event, marked: true, trigger: 'save' }))).toEqual({ action: 'none' });
|
||||
});
|
||||
|
||||
it('marks the event it creates and keeps the mark when it moves it', async () => {
|
||||
useSettingsStore.setState({ vacationBlockCalendar: true, vacationCalendarBlocks: {}, timeZone: 'Europe/Berlin' });
|
||||
const client = fakeClient(null);
|
||||
await syncVacationBlock(client, undefined, reply(), 'save', NOW);
|
||||
expect(client.createCalendarEvent.mock.calls[0][0]).toMatchObject({ keywords: { 'bulwark-out-of-office': true } });
|
||||
// Another device, no record: the reply\'s old dates make the event ours.
|
||||
useSettingsStore.setState({ vacationCalendarBlocks: {} });
|
||||
const ours = { id: 'e1', ...blockTimes(new Date(last.start), new Date(last.end), 'Europe/Berlin') };
|
||||
const other = fakeClient(ours);
|
||||
expect(await syncVacationBlock(other, undefined, reply({ isEnabled: false }), 'save', NOW, { fromDate: last.start, toDate: last.end })).toBe('destroy');
|
||||
useSettingsStore.setState({ timeZone: 'auto' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveBlockSetting', () => {
|
||||
it('pins the organisation\'s value when locked and hides the choice when hidden', () => {
|
||||
expect(resolveBlockSetting({ locked: true, value: false }, undefined, true)).toEqual({ value: false, locked: true, hidden: false });
|
||||
// A lock without a value takes the policy default, not the user's choice.
|
||||
expect(resolveBlockSetting({ locked: true }, false, true)).toEqual({ value: false, locked: true, hidden: false });
|
||||
expect(resolveBlockSetting({ locked: true }, undefined, false)).toEqual({ value: true, locked: true, hidden: false });
|
||||
expect(resolveBlockSetting({ hidden: true }, undefined, false)).toEqual({ value: true, locked: false, hidden: true });
|
||||
expect(resolveBlockSetting(undefined, undefined, false)).toEqual({ value: false, locked: false, hidden: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the sync scheduler with several accounts', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
resetVacationCalendarSync();
|
||||
useSettingsStore.setState({ vacationBlockCalendar: true, vacationCalendarBlocks: {} });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
resetVacationCalendarSync();
|
||||
});
|
||||
|
||||
it('keeps one job per account, and the dates from before the first of several saves', async () => {
|
||||
const client = fakeClient(null);
|
||||
vacationReplySaved(client, undefined, reply(), reply({ subject: 'A' }));
|
||||
vacationReplySaved(client, 'boss', reply(), reply({ subject: 'B' }));
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
const accounts = client.queryCalendarEvents.mock.calls.map((c) => (c as unknown[])[3]);
|
||||
expect(accounts.sort()).toEqual(['boss', undefined].sort());
|
||||
});
|
||||
|
||||
it('syncs several quick saves once, against the dates from before the first', async () => {
|
||||
useSettingsStore.setState({ timeZone: 'Europe/Berlin' });
|
||||
const ours = { id: 'e1', ...blockTimes(new Date(last.start), new Date(last.end), 'Europe/Berlin') };
|
||||
const client = fakeClient(ours);
|
||||
const moved = reply({ toDate: '2026-12-09T17:00:00Z' });
|
||||
vacationReplySaved(client, undefined, reply(), moved);
|
||||
vacationReplySaved(client, undefined, moved, { ...moved, isEnabled: false });
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(client.queryCalendarEvents).toHaveBeenCalledTimes(1);
|
||||
expect(client.deleteCalendarEvent).toHaveBeenCalledWith('e1', false, undefined);
|
||||
useSettingsStore.setState({ timeZone: 'auto' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { allFeeds, feedForSubject, intranetFeedHost, stripFeedUrls } from '../company-feeds';
|
||||
import { listReachableHosts } from '../reachable-hosts';
|
||||
import { sanitizeOrganisationPolicy } from '../organisation-policy';
|
||||
import type { SettingsPolicy } from '../types';
|
||||
|
||||
const policy = {
|
||||
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
|
||||
organisation: { calendars: { feeds: [{ id: 'hol', name: 'Holidays', url: 'https://cal.example.org/h.ics?token=t', color: '#2563eb', refreshMinutes: 60 }], freeBusyGroup: '' } },
|
||||
scopes: [
|
||||
{ id: 'team', label: 'Team', match: { groups: ['team@example.org'] }, policy: { organisation: { calendars: { feeds: [{ id: 'team', name: 'Team', url: 'http://intranet.example.org/team.ics', refreshMinutes: 120 }] } } } },
|
||||
{ id: 'two', label: 'Org two', match: { domains: ['two.example'] }, policy: { organisation: { calendars: { feeds: [{ id: 'two', name: 'Two', url: 'https://two.example/cal.ics' }] } } } },
|
||||
],
|
||||
} as unknown as SettingsPolicy;
|
||||
|
||||
describe('company feeds on the server', () => {
|
||||
it('empties every address in the policy users get, and the browser keeps the feeds', () => {
|
||||
const stripped = stripFeedUrls(policy);
|
||||
expect(stripped.organisation?.calendars.feeds[0]).toMatchObject({ id: 'hol', name: 'Holidays', url: '', urlHeld: true });
|
||||
expect((stripped.scopes![0].policy.organisation as { calendars: { feeds: { url: string }[] } }).calendars.feeds[0].url).toBe('');
|
||||
// The original is untouched (the admin pages read it).
|
||||
expect(policy.organisation?.calendars.feeds[0].url).toContain('token=t');
|
||||
// An emptied address survives the sanitiser the browser runs.
|
||||
expect(sanitizeOrganisationPolicy(stripped.organisation).calendars.feeds.map((f) => [f.id, f.url])).toEqual([['hol', '']]);
|
||||
});
|
||||
|
||||
it('resolves a feed id only for a subject its scope applies to', () => {
|
||||
expect(feedForSubject(policy, { email: 'a@example.org', groups: ['team@example.org'] }, 'team')?.url).toBe('http://intranet.example.org/team.ics');
|
||||
expect(feedForSubject(policy, { email: 'a@example.org', groups: [] }, 'team')).toBeNull();
|
||||
expect(feedForSubject(policy, { email: 'a@example.org', groups: [] }, 'hol')?.name).toBe('Holidays');
|
||||
expect(feedForSubject(policy, { email: 'a@example.org', groups: [] }, 'two')).toBeNull();
|
||||
expect(feedForSubject(policy, { email: 'b@two.example', groups: [] }, 'two')?.name).toBe('Two');
|
||||
});
|
||||
|
||||
it('lifts the private-address block only for a host that names the intranet', () => {
|
||||
expect(intranetFeedHost({ url: 'http://calendar.corp.internal/team.ics' })).toBe('calendar.corp.internal');
|
||||
expect(intranetFeedHost({ url: 'http://intranet/team.ics' })).toBe('intranet');
|
||||
expect(intranetFeedHost({ url: 'http://10.1.2.3/team.ics' })).toBe('10.1.2.3');
|
||||
expect(intranetFeedHost({ url: 'https://cal.example.org/h.ics' })).toBeNull();
|
||||
expect(intranetFeedHost({ url: 'http://127.0.0.1:8080/h.ics' })).toBeNull();
|
||||
expect(intranetFeedHost({ url: 'http://169.254.169.254/latest' })).toBeNull();
|
||||
expect(intranetFeedHost({ url: 'http://localhost/h.ics' })).toBeNull();
|
||||
});
|
||||
|
||||
it('lists every scope\'s feed host as reachable', () => {
|
||||
expect(allFeeds(policy).map((f) => f.id)).toEqual(['hol', 'team', 'two']);
|
||||
const hosts = listReachableHosts({ get: () => undefined, policy, env: {} })
|
||||
.filter((h) => h.kind === 'Company calendar');
|
||||
expect(hosts.map((h) => [h.host, h.setIn, h.side])).toEqual([
|
||||
['cal.example.org', 'Organisation > Calendars (Holidays)', 'server'],
|
||||
['intranet.example.org', 'Organisation > Calendars (Team)', 'server'],
|
||||
['two.example', 'Organisation > Calendars (Two)', 'server'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -85,6 +85,15 @@ describe('enterprise sections: defaults and sanitising', () => {
|
||||
expect(calendars.freeBusyGroup).toBe('all-staff@example.org');
|
||||
});
|
||||
|
||||
it('keeps a feed whose address the server held back, and drops one without an address', () => {
|
||||
const { calendars } = sanitizeOrganisationPolicy({ calendars: { feeds: [
|
||||
{ id: 'kept', name: 'Holidays', url: '', urlHeld: true },
|
||||
{ id: 'none', name: 'No address' },
|
||||
{ id: 'empty', name: 'Typed nothing yet', url: '' },
|
||||
] } });
|
||||
expect(calendars.feeds.map((f) => [f.id, f.url, f.urlHeld])).toEqual([['kept', '', true]]);
|
||||
});
|
||||
|
||||
it('keeps device switches only as booleans', () => {
|
||||
const { devices } = sanitizeOrganisationPolicy({ devices: { noDownloads: true, noPrint: 'yes', watermark: 1 } });
|
||||
expect(devices.noDownloads).toBe(true);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Company calendar feeds as the server last fetched them, by address: the
|
||||
* body, its validators and when it was fetched. In memory, once per process
|
||||
* (not per bundle copy), and at most MAX_ENTRIES feeds; a replica that
|
||||
* starts fetches again.
|
||||
*/
|
||||
|
||||
export interface CachedFeed {
|
||||
body: ArrayBuffer;
|
||||
etag: string | null;
|
||||
lastModified: string | null;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
/** What a fetch of a feed gave: its body, or the error to answer with. */
|
||||
export type FeedResult = { body: ArrayBuffer } | { status: number; error: string };
|
||||
|
||||
const MAX_ENTRIES = 200;
|
||||
/** How long a failed feed answers with its error before it is asked again. */
|
||||
export const FAILURE_TTL_MS = 60_000;
|
||||
const KEY = Symbol.for('bulwark.companyFeedCache');
|
||||
|
||||
interface CacheState {
|
||||
feeds: Map<string, CachedFeed>;
|
||||
failures: Map<string, { at: number; result: { status: number; error: string } }>;
|
||||
inFlight: Map<string, Promise<FeedResult>>;
|
||||
}
|
||||
|
||||
function cacheState(): CacheState {
|
||||
const g = globalThis as typeof globalThis & { [KEY]?: CacheState };
|
||||
g[KEY] ??= { feeds: new Map(), failures: new Map(), inFlight: new Map() };
|
||||
return g[KEY];
|
||||
}
|
||||
|
||||
function store(): Map<string, CachedFeed> {
|
||||
return cacheState().feeds;
|
||||
}
|
||||
|
||||
export const companyFeedCache = {
|
||||
get(url: string): CachedFeed | undefined {
|
||||
return store().get(url);
|
||||
},
|
||||
set(url: string, entry: CachedFeed): void {
|
||||
const map = store();
|
||||
map.delete(url);
|
||||
map.set(url, entry);
|
||||
// The oldest entry goes first.
|
||||
while (map.size > MAX_ENTRIES) map.delete(map.keys().next().value as string);
|
||||
},
|
||||
/** The error a feed failed with less than a minute ago, if it did. */
|
||||
failure(url: string): { status: number; error: string } | null {
|
||||
const failures = cacheState().failures;
|
||||
const entry = failures.get(url);
|
||||
if (!entry) return null;
|
||||
if (Date.now() - entry.at >= FAILURE_TTL_MS) {
|
||||
failures.delete(url);
|
||||
return null;
|
||||
}
|
||||
return entry.result;
|
||||
},
|
||||
fail(url: string, result: { status: number; error: string }): void {
|
||||
cacheState().failures.set(url, { at: Date.now(), result });
|
||||
},
|
||||
/** Run `fetch` for a feed once at a time: callers meanwhile share its result. */
|
||||
once(url: string, fetch: () => Promise<FeedResult>): Promise<FeedResult> {
|
||||
const inFlight = cacheState().inFlight;
|
||||
let running = inFlight.get(url);
|
||||
if (!running) {
|
||||
running = fetch().finally(() => inFlight.delete(url));
|
||||
inFlight.set(url, running);
|
||||
}
|
||||
return running;
|
||||
},
|
||||
clear(): void {
|
||||
const state = cacheState();
|
||||
state.feeds.clear();
|
||||
state.failures.clear();
|
||||
state.inFlight.clear();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Company calendars on the server (organisation.calendars.feeds).
|
||||
*
|
||||
* A feed address may carry a token, so full Bulwark never hands it to the
|
||||
* browser: the policy users get has every feed's `url` emptied
|
||||
* (stripFeedUrls), and /api/fetch-ical fetches a feed by id from the
|
||||
* caller's own scoped policy (feedForSubject). Lite has no server: there the
|
||||
* address is in the public policy.json, which the admin docs say.
|
||||
*/
|
||||
|
||||
import type { SettingsPolicy } from './types';
|
||||
import type { CompanyFeed } from './organisation-policy';
|
||||
import { resolvePolicy, type PolicySubject } from './policy-scopes';
|
||||
import { isIntranetHostName } from '@/lib/security/url-guard';
|
||||
|
||||
function withoutUrls(organisation: unknown): unknown {
|
||||
if (!organisation || typeof organisation !== 'object') return organisation;
|
||||
const org = organisation as Record<string, unknown>;
|
||||
const calendars = org.calendars as Record<string, unknown> | undefined;
|
||||
if (!calendars || !Array.isArray(calendars.feeds)) return organisation;
|
||||
return {
|
||||
...org,
|
||||
calendars: {
|
||||
...calendars,
|
||||
feeds: calendars.feeds.map((feed) => (feed && typeof feed === 'object' ? { ...(feed as Record<string, unknown>), url: '', urlHeld: true } : feed)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The policy as users get it: every company calendar's address emptied, in Everyone and in every scope. */
|
||||
export function stripFeedUrls(policy: SettingsPolicy): SettingsPolicy {
|
||||
return {
|
||||
...policy,
|
||||
...(policy.organisation ? { organisation: withoutUrls(policy.organisation) as SettingsPolicy['organisation'] } : {}),
|
||||
...(policy.scopes
|
||||
? {
|
||||
scopes: policy.scopes.map((scope) => (scope.policy?.organisation
|
||||
? { ...scope, policy: { ...scope.policy, organisation: withoutUrls(scope.policy.organisation) as typeof scope.policy.organisation } }
|
||||
: scope)),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The feed `feedId` as it applies to `subject` (their domain and group scopes), or null when it doesn't. */
|
||||
export function feedForSubject(policy: SettingsPolicy, subject: PolicySubject, feedId: string): CompanyFeed | null {
|
||||
const feeds = resolvePolicy(policy, subject).policy.organisation?.calendars?.feeds ?? [];
|
||||
const feed = feeds.find((f) => f.id === feedId);
|
||||
return feed && feed.url ? feed : null;
|
||||
}
|
||||
|
||||
/** Every feed of every scope, for the outgoing-hosts list. */
|
||||
export function allFeeds(policy: Pick<SettingsPolicy, 'organisation' | 'scopes'>): CompanyFeed[] {
|
||||
const lists = [policy.organisation, ...(policy.scopes ?? []).map((scope) => scope.policy?.organisation)]
|
||||
.map((org) => (org as { calendars?: { feeds?: unknown } } | undefined)?.calendars?.feeds);
|
||||
return lists.flatMap((feeds) => (Array.isArray(feeds) ? feeds.filter((f): f is CompanyFeed => !!f && typeof f === 'object' && typeof (f as CompanyFeed).url === 'string') : []));
|
||||
}
|
||||
|
||||
/**
|
||||
* The feed's host when it names the intranet (an intranet address, a
|
||||
* .local/.internal/.localdomain name or a single-label name): the only kind
|
||||
* of host a feed fetch may reach on a private address. Null for public
|
||||
* names, so a public host whose DNS is later pointed inside reaches nothing.
|
||||
*/
|
||||
export function intranetFeedHost(feed: Pick<CompanyFeed, 'url'>): string | null {
|
||||
try {
|
||||
const host = new URL(feed.url).hostname.toLowerCase();
|
||||
return isIntranetHostName(host) ? host : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,14 @@ export interface VacationTemplate {
|
||||
export interface CompanyFeed {
|
||||
id: string;
|
||||
name: string;
|
||||
/** The ICS address; '' when `urlHeld`. */
|
||||
url: string;
|
||||
/**
|
||||
* Set in the policy full Bulwark hands to users: the server keeps the
|
||||
* address back (it may hold a token) and fetches the feed by id
|
||||
* (app/api/fetch-ical, lib/admin/company-feeds.ts).
|
||||
*/
|
||||
urlHeld?: boolean;
|
||||
/** #rrggbb */
|
||||
color: string;
|
||||
refreshMinutes: number;
|
||||
@@ -407,10 +414,12 @@ function feedUrl(value: unknown): string | null {
|
||||
function sanitizeFeeds(value: unknown): CompanyFeed[] | undefined {
|
||||
return byIdList(value, MAX_FEEDS, (item, id) => {
|
||||
const name = line(item.name, 100);
|
||||
const url = feedUrl(item.url);
|
||||
if (!name || !url) return null;
|
||||
// An empty address only for a feed whose address the server held back.
|
||||
const held = item.urlHeld === true && item.url === '';
|
||||
const url = held ? '' : feedUrl(item.url);
|
||||
if (!name || url === null) return null;
|
||||
const minutes = count(item.refreshMinutes, 7 * 24 * 60) ?? MIN_FEED_REFRESH_MINUTES;
|
||||
return { id, name, url, color: color(item.color, '#2563eb'), refreshMinutes: Math.max(MIN_FEED_REFRESH_MINUTES, minutes) };
|
||||
return { id, name, url, color: color(item.color, '#2563eb'), refreshMinutes: Math.max(MIN_FEED_REFRESH_MINUTES, minutes), ...(held ? { urlHeld: true } : {}) };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SettingsPolicy } from './types';
|
||||
import { parseJmapServers } from './jmap-servers';
|
||||
import { isBuiltInHost } from '@/lib/outgoing-hosts';
|
||||
import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
|
||||
import { allFeeds } from './company-feeds';
|
||||
|
||||
/**
|
||||
* What stays reachable with "no third-party traffic" on: the hosts the
|
||||
@@ -39,7 +40,7 @@ function parse(url: unknown): URL | null {
|
||||
|
||||
export interface ReachableInput {
|
||||
get: (key: string) => unknown;
|
||||
policy: Pick<SettingsPolicy, 'pushRelays' | 'pushRelayUrl' | 'defaultSidebarApps'> & { features?: Partial<SettingsPolicy['features']> };
|
||||
policy: Pick<SettingsPolicy, 'pushRelays' | 'pushRelayUrl' | 'defaultSidebarApps'> & Partial<Pick<SettingsPolicy, 'organisation' | 'scopes'>> & { features?: Partial<SettingsPolicy['features']> };
|
||||
env: Record<string, string | undefined>;
|
||||
/** The relay the browser bundle was built with (NEXT_PUBLIC_PUSH_RELAY_URL inlined at build time). */
|
||||
buildRelayUrl?: string;
|
||||
@@ -88,6 +89,9 @@ export function listReachableHosts({ get, policy, env, buildRelayUrl = DEFAULT_R
|
||||
'Set in the environment only: browsers use the relay this build was made with. Rebuild, or add it under User policy > Push relays.');
|
||||
}
|
||||
add('Extension directory', get('extensionDirectoryUrl'), 'EXTENSION_DIRECTORY_URL', 'server');
|
||||
// Company calendars of Everyone and of every scope: this server fetches
|
||||
// them (app/api/fetch-ical by feed id), whatever the switch says.
|
||||
for (const feed of allFeeds(policy)) add('Company calendar', feed.url, `Organisation > Calendars (${feed.name})`, 'server');
|
||||
for (const app of policy.defaultSidebarApps ?? []) add('Sidebar app', app?.url, `User policy > Sidebar apps (${app?.name ?? ''})`, 'browser');
|
||||
add('Document editor (WOPI)', get('wopiClientUrl'), 'WOPI_CLIENT_URL', 'both');
|
||||
add('Translation (LibreTranslate)', env.LIBRETRANSLATE_URL, 'LIBRETRANSLATE_URL', 'server');
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Who a request is for the domain and group scopes, worked out on the
|
||||
* server from the caller's own JMAP session: the account's address and the
|
||||
* group accounts in its session, as policySubjectForClient
|
||||
* (lib/admin/policy-subject.ts) does in the browser. Used where the server
|
||||
* must apply a group scope itself (company calendars by feed id), so a
|
||||
* browser can't claim a group it isn't in.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { PolicySubject } from './policy-scopes';
|
||||
import type { StalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { fetchJmapSession, postJmap, rebaseApiUrl } from '@/lib/stalwart/jmap-api';
|
||||
import { UPSTREAM_JSON_MAX_BYTES, readJsonCapped } from '@/lib/security/capped-body';
|
||||
|
||||
const TTL_MS = 5 * 60 * 1000;
|
||||
const PRINCIPALS = 'urn:ietf:params:jmap:principals';
|
||||
const MAIL = 'urn:ietf:params:jmap:mail';
|
||||
|
||||
type Creds = Pick<StalwartCredentials, 'serverUrl' | 'authHeader' | 'accountName' | 'username' | 'trusted'>;
|
||||
|
||||
interface SessionAccount {
|
||||
name?: string;
|
||||
isPersonal?: boolean;
|
||||
}
|
||||
|
||||
// One cache per process, not per bundle copy.
|
||||
const CACHE_KEY = Symbol.for('bulwark.serverPolicySubjects');
|
||||
function cache(): Map<string, { at: number; subject: Promise<PolicySubject> }> {
|
||||
const g = globalThis as typeof globalThis & { [CACHE_KEY]?: Map<string, { at: number; subject: Promise<PolicySubject> }> };
|
||||
g[CACHE_KEY] ??= new Map();
|
||||
return g[CACHE_KEY];
|
||||
}
|
||||
|
||||
async function resolve(creds: Creds): Promise<PolicySubject> {
|
||||
const email = (creds.accountName || creds.username).trim().toLowerCase();
|
||||
const session = await fetchJmapSession(creds.serverUrl, creds.authHeader, { trusted: creds.trusted });
|
||||
if (!session?.accounts) return { email, groups: [] };
|
||||
const primary = session.primaryAccounts?.[MAIL] ?? '';
|
||||
const shared = Object.entries(session.accounts as Record<string, SessionAccount>)
|
||||
.filter(([id, account]) => id !== primary && account?.isPersonal === false && typeof account.name === 'string' && account.name.includes('@'));
|
||||
if (shared.length === 0) return { email, groups: [] };
|
||||
|
||||
// A mailbox another person shared is non-personal too: the principal type
|
||||
// tells groups apart. When the server won't say, every non-personal
|
||||
// account counts, as in the browser.
|
||||
let types: Record<string, string> | null = null;
|
||||
const apiUrl = rebaseApiUrl(session, creds.serverUrl);
|
||||
if (apiUrl && primary) {
|
||||
try {
|
||||
const response = await postJmap(apiUrl, creds.authHeader, JSON.stringify({
|
||||
using: ['urn:ietf:params:jmap:core', PRINCIPALS],
|
||||
methodCalls: [['Principal/get', { accountId: primary, ids: shared.map(([id]) => id), properties: ['id', 'type'] }, '0']],
|
||||
}), { trusted: creds.trusted });
|
||||
if (response.ok) {
|
||||
const body = await readJsonCapped<{ methodResponses?: [string, { list?: Array<{ id?: string; type?: string }> }, string][] }>(response, UPSTREAM_JSON_MAX_BYTES);
|
||||
const [method, result] = body?.methodResponses?.[0] ?? [];
|
||||
if (method === 'Principal/get') {
|
||||
types = {};
|
||||
for (const p of result?.list ?? []) if (p.id && p.type) types[p.id] = p.type;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
types = null;
|
||||
}
|
||||
}
|
||||
const groups = shared
|
||||
.filter(([id]) => !types || types[id] === 'group')
|
||||
.map(([, account]) => account.name!.trim().toLowerCase())
|
||||
.sort();
|
||||
return { email, groups };
|
||||
}
|
||||
|
||||
/** The caller's subject, kept for a few minutes per credential. Never rejects. */
|
||||
export function serverPolicySubject(creds: Creds): Promise<PolicySubject> {
|
||||
const key = createHash('sha256').update(`${creds.serverUrl}\n${creds.authHeader}`).digest('hex');
|
||||
const entries = cache();
|
||||
const now = Date.now();
|
||||
const hit = entries.get(key);
|
||||
if (hit && now - hit.at < TTL_MS) return hit.subject;
|
||||
const email = (creds.accountName || creds.username).trim().toLowerCase();
|
||||
const subject = resolve(creds).catch((): PolicySubject => ({ email, groups: [] }));
|
||||
entries.set(key, { at: now, subject });
|
||||
if (entries.size > 1000) {
|
||||
for (const [k, v] of entries) if (now - v.at >= TTL_MS) entries.delete(k);
|
||||
}
|
||||
return subject;
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
export function clearServerPolicySubjects(): void {
|
||||
cache().clear();
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Answering an invitation for someone else (#634).
|
||||
*
|
||||
* A delegate with edit access (mayWriteAll) to another person's calendar
|
||||
* answers an invitation in that calendar as its owner: the patch sets the
|
||||
* owner's participationStatus with `sentBy = "mailto:<delegate>"` in the
|
||||
* owner's account, and Stalwart sends the organiser a REPLY from the owner
|
||||
* with ATTENDEE;SENT-BY (live on 0.16 and 1.0,
|
||||
* local-data/enterprise-pack-facts.md part 6 §2). mayRSVP alone is refused
|
||||
* by the server. A group account's members answer as the group without
|
||||
* SENT-BY: a member is not a delegate.
|
||||
*
|
||||
* Who "you" are depends on the calendar: in an account of another person or
|
||||
* a group, its owner's addresses pick the participant, not the user's own.
|
||||
*
|
||||
* Stalwart 0.16 counts the delegate's and the owner's addresses as local, so
|
||||
* with both invited it refuses the change ("Cannot modify address of the
|
||||
* event", verified live). There the owner bar is not offered when the user is
|
||||
* invited too.
|
||||
*/
|
||||
|
||||
import type { Calendar, CalendarEvent, CalendarParticipant, Principal } from '@/lib/jmap/types';
|
||||
import { eventHasNoOwner } from '@/lib/calendar-editability';
|
||||
import { getUserParticipantId, isOrganizer } from '@/lib/calendar-participants';
|
||||
|
||||
type Status = CalendarParticipant['participationStatus'];
|
||||
|
||||
/** The owner of a shared account, as Bulwark shows and matches it. */
|
||||
export interface AccountOwner {
|
||||
name: string;
|
||||
/** Lower-case addresses the owner's participant may carry. */
|
||||
emails: string[];
|
||||
isGroup: boolean;
|
||||
}
|
||||
|
||||
/** The owner from Principal/get, else from the session's account name. */
|
||||
export function accountOwnerOf(principal: Principal | null | undefined, accountName: string | undefined): AccountOwner | null {
|
||||
const emails = new Set<string>();
|
||||
if (principal?.email) emails.add(principal.email.trim().toLowerCase());
|
||||
if (accountName?.includes('@')) emails.add(accountName.trim().toLowerCase());
|
||||
if (emails.size === 0) return null;
|
||||
const name = principal?.description?.trim() || principal?.name?.trim() || accountName || [...emails][0];
|
||||
return { name, emails: [...emails], isGroup: principal?.type === 'group' };
|
||||
}
|
||||
|
||||
export interface AnswerTarget {
|
||||
kind: 'self' | 'owner';
|
||||
/** The store event the answer is written to. */
|
||||
eventId: string;
|
||||
participantId: string;
|
||||
status: Status | null;
|
||||
/** `mailto:<user>` when answering for a person; left out otherwise. */
|
||||
sentBy?: string;
|
||||
/** Whose answer it is, for an owner target. */
|
||||
ownerName?: string;
|
||||
}
|
||||
|
||||
export interface AnswerView {
|
||||
targets: AnswerTarget[];
|
||||
/** Why there is no bar for the owner, when they are invited. */
|
||||
note: { kind: 'no_write' | 'also_invited'; owner: string } | null;
|
||||
/** Who answered last for the participant shown, when someone did for them (sentBy). */
|
||||
answeredBy: string | null;
|
||||
}
|
||||
|
||||
function addressOf(sentBy: unknown): string | null {
|
||||
if (typeof sentBy !== 'string' || !sentBy.trim()) return null;
|
||||
return sentBy.trim().replace(/^mailto:/i, '');
|
||||
}
|
||||
|
||||
export interface ResolveAnswerInput {
|
||||
event: CalendarEvent;
|
||||
/** The event's calendar as the store holds it. */
|
||||
calendar?: Pick<Calendar, 'isShared' | 'myRights'> | null;
|
||||
/** The signed-in user's addresses; the first is the one answers are sent by. */
|
||||
userEmails: string[];
|
||||
/** Whether the existing rules let the user answer for themselves on this event (canUserRsvp). */
|
||||
canRsvpSelf: boolean;
|
||||
/** The owner of the event's account, for an event in a shared calendar. */
|
||||
owner: AccountOwner | null;
|
||||
/** The user's own copy of the event (same uid, own calendar), if loaded. */
|
||||
ownCopy?: CalendarEvent | null;
|
||||
/** The server is Stalwart 0.16 (see the module comment). */
|
||||
server016: boolean;
|
||||
}
|
||||
|
||||
/** Which answer bars an event shows, and what to say instead of one. */
|
||||
export function resolveAnswerTargets(input: ResolveAnswerInput): AnswerView {
|
||||
const { event, calendar, userEmails, owner, ownCopy } = input;
|
||||
const selfId = getUserParticipantId(event, userEmails);
|
||||
const selfTarget = (): AnswerTarget[] => (input.canRsvpSelf && selfId
|
||||
? [{ kind: 'self', eventId: event.id, participantId: selfId, status: event.participants?.[selfId]?.participationStatus ?? null }]
|
||||
: []);
|
||||
const ownCopyTarget = (): AnswerTarget[] => {
|
||||
if (!ownCopy) return [];
|
||||
const id = getUserParticipantId(ownCopy, userEmails);
|
||||
return id ? [{ kind: 'self', eventId: ownCopy.id, participantId: id, status: ownCopy.participants?.[id]?.participationStatus ?? null }] : [];
|
||||
};
|
||||
|
||||
if (calendar?.isShared && owner) {
|
||||
const ownerId = getUserParticipantId(event, owner.emails);
|
||||
// Members of a group often have the group's address as an identity of
|
||||
// their own: the group's participant is still answered as the group.
|
||||
const ownersOwn = ownerId && (ownerId !== selfId || owner.isGroup);
|
||||
if (ownersOwn && !isOrganizer(event, owner.emails) && !eventHasNoOwner(event)) {
|
||||
const participant = event.participants![ownerId!];
|
||||
const answeredBy = addressOf(participant.sentBy);
|
||||
if (!calendar.myRights?.mayWriteAll) {
|
||||
return { targets: selfTarget(), note: { kind: 'no_write', owner: owner.name }, answeredBy };
|
||||
}
|
||||
const alsoInvited = !!selfId && selfId !== ownerId;
|
||||
if (input.server016 && alsoInvited) {
|
||||
return { targets: ownCopyTarget(), note: { kind: 'also_invited', owner: owner.name }, answeredBy };
|
||||
}
|
||||
const delegate = userEmails[0]?.trim().toLowerCase();
|
||||
const ownerTarget: AnswerTarget = {
|
||||
kind: 'owner',
|
||||
eventId: event.id,
|
||||
participantId: ownerId!,
|
||||
status: participant.participationStatus ?? null,
|
||||
ownerName: owner.name,
|
||||
// Always the mailto: form: Stalwart passes sentBy through unchecked,
|
||||
// and RFC 5545's SENT-BY needs a cal-address.
|
||||
...(!owner.isGroup && delegate ? { sentBy: `mailto:${delegate}` } : {}),
|
||||
};
|
||||
return { targets: [ownerTarget, ...(alsoInvited ? ownCopyTarget() : [])], note: null, answeredBy };
|
||||
}
|
||||
}
|
||||
|
||||
const answeredBy = selfId ? addressOf(event.participants?.[selfId]?.sentBy) : null;
|
||||
return { targets: selfTarget(), note: null, answeredBy };
|
||||
}
|
||||
|
||||
/**
|
||||
* The sentBy part of an answer's patch: the delegate's address when one
|
||||
* answers for the owner, null to clear an old one when the participant
|
||||
* answers themselves (Stalwart keeps the old SENT-BY on the next reply
|
||||
* otherwise), nothing when there is nothing to change.
|
||||
*/
|
||||
export function sentByPatchValue(participant: Pick<CalendarParticipant, 'sentBy'> | undefined, sentBy: string | undefined): string | null | undefined {
|
||||
if (sentBy) return sentBy;
|
||||
return participant?.sentBy ? null : undefined;
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Company calendars (organisation.calendars.feeds): ICS feeds the admin
|
||||
* lists, shown to every user in scope as read-only calendars under
|
||||
* "Organisation".
|
||||
*
|
||||
* They are virtual calendars in the browser, like the birthday calendar, not
|
||||
* calendars imported into each user's account (the subscription sync in
|
||||
* lib/calendar-subscription-sync.ts does that): nothing lands in anyone's
|
||||
* free/busy, removing a feed removes it everywhere at once, and Lite runs the
|
||||
* same code. What is reused from the subscriptions is the fetch (full
|
||||
* Bulwark: /api/fetch-ical, here by feed id so the address stays on the
|
||||
* server) and the parsing (CalendarEvent/parse on an uploaded blob, so there
|
||||
* is no ICS parser in the bundle).
|
||||
*
|
||||
* The upload quota (1000 files or 50 MB an hour on Stalwart) is spared: a
|
||||
* feed is fetched at most once per refresh interval (60 minutes or more),
|
||||
* its text is hashed and not uploaded again while it is unchanged, and the
|
||||
* parsed events are kept per account in IndexedDB, which a full sign-out
|
||||
* deletes. Uploads take the XHR path, so a 429 there doesn't freeze the rest
|
||||
* of the client.
|
||||
*/
|
||||
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
import type { CompanyFeed } from '@/lib/admin/organisation-policy';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { onBeforeSignOut, onFullSignOut } from '@/lib/sign-out-cleanup';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { debug } from '@/lib/debug';
|
||||
|
||||
export const COMPANY_CALENDAR_PREFIX = '__company-calendar__:';
|
||||
|
||||
export function companyCalendarId(feedId: string): string {
|
||||
return `${COMPANY_CALENDAR_PREFIX}${feedId}`;
|
||||
}
|
||||
|
||||
export function isCompanyCalendarId(id: string): boolean {
|
||||
return id.startsWith(COMPANY_CALENDAR_PREFIX);
|
||||
}
|
||||
|
||||
export function feedIdOf(calendarId: string): string {
|
||||
return calendarId.slice(COMPANY_CALENDAR_PREFIX.length);
|
||||
}
|
||||
|
||||
/** The read-only calendar a feed is shown as. */
|
||||
export function createCompanyCalendar(feed: Pick<CompanyFeed, 'id' | 'name' | 'color'>): Calendar {
|
||||
return {
|
||||
id: companyCalendarId(feed.id),
|
||||
name: feed.name,
|
||||
description: null,
|
||||
color: feed.color || '#2563eb',
|
||||
sortOrder: 1000,
|
||||
isSubscribed: true,
|
||||
isVisible: true,
|
||||
isDefault: false,
|
||||
includeInAvailability: 'none',
|
||||
defaultAlertsWithTime: null,
|
||||
defaultAlertsWithoutTime: null,
|
||||
timeZone: null,
|
||||
shareWith: null,
|
||||
isCompany: true,
|
||||
myRights: {
|
||||
mayReadFreeBusy: true,
|
||||
mayReadItems: true,
|
||||
mayWriteAll: false,
|
||||
mayWriteOwn: false,
|
||||
mayUpdatePrivate: false,
|
||||
mayRSVP: false,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The parsed events of a feed as events of its calendar: no attendees, nothing to answer. */
|
||||
export function toCompanyEvents(feedId: string, parsed: Partial<CalendarEvent>[]): CalendarEvent[] {
|
||||
const calendarId = companyCalendarId(feedId);
|
||||
const events: CalendarEvent[] = [];
|
||||
parsed.forEach((event, index) => {
|
||||
if (typeof event.start !== 'string' || !event.start) return;
|
||||
const rest = { ...(event as Record<string, unknown>) };
|
||||
delete rest.method;
|
||||
events.push({
|
||||
...(rest as Partial<CalendarEvent>),
|
||||
id: `company-${feedId}-${index}`,
|
||||
uid: event.uid || `company-${feedId}-${index}`,
|
||||
title: event.title || '',
|
||||
calendarIds: { [calendarId]: true },
|
||||
// A feed's ATTENDEE lines are someone else's invitation: shown as an
|
||||
// event, never as one to answer.
|
||||
participants: null,
|
||||
replyTo: null,
|
||||
organizerCalendarAddress: null,
|
||||
isDraft: false,
|
||||
isOrigin: false,
|
||||
alerts: null,
|
||||
useDefaultAlerts: false,
|
||||
} as CalendarEvent);
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
export interface FeedCacheEntry {
|
||||
/** SHA-256 of the feed text the events were parsed from. */
|
||||
hash: string;
|
||||
fetchedAt: number;
|
||||
events: Partial<CalendarEvent>[];
|
||||
}
|
||||
|
||||
// ── IndexedDB, per account ──────────────────────────────────────────
|
||||
|
||||
/** Also deleted by name in lib/sign-out-cleanup.ts, whether this module was loaded or not. */
|
||||
export const COMPANY_CALENDAR_DB = 'bulwark-company-calendars';
|
||||
const DB_NAME = COMPANY_CALENDAR_DB;
|
||||
const STORE = 'feeds';
|
||||
|
||||
function openDb(): Promise<IDBDatabase | null> {
|
||||
if (typeof indexedDB === 'undefined') return Promise.resolve(null);
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const request = indexedDB.open(DB_NAME, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore(STORE);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => resolve(null);
|
||||
request.onblocked = () => resolve(null);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function idbGet(key: string): Promise<FeedCacheEntry | null> {
|
||||
const db = await openDb();
|
||||
if (!db) return null;
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const request = db.transaction(STORE, 'readonly').objectStore(STORE).get(key);
|
||||
request.onsuccess = () => resolve((request.result as FeedCacheEntry | undefined) ?? null);
|
||||
request.onerror = () => resolve(null);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
}).finally(() => db.close()) as Promise<FeedCacheEntry | null>;
|
||||
}
|
||||
|
||||
async function idbPut(key: string, value: FeedCacheEntry): Promise<void> {
|
||||
const db = await openDb();
|
||||
if (!db) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
try {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
tx.objectStore(STORE).put(value, key);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => resolve();
|
||||
tx.onabort = () => resolve();
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function idbDelete(match: (key: string) => boolean): Promise<void> {
|
||||
const db = await openDb();
|
||||
if (!db) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
try {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
const store = tx.objectStore(STORE);
|
||||
const request = store.getAllKeys();
|
||||
request.onsuccess = () => {
|
||||
for (const key of request.result) if (typeof key === 'string' && match(key)) store.delete(key);
|
||||
};
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => resolve();
|
||||
tx.onabort = () => resolve();
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
db.close();
|
||||
}
|
||||
|
||||
const memory = new Map<string, FeedCacheEntry>();
|
||||
|
||||
/** Drop every cached feed: on a full sign-out the events are the organisation's. */
|
||||
export function clearCompanyCalendarCache(): void {
|
||||
memory.clear();
|
||||
inFlight.clear();
|
||||
if (typeof indexedDB === 'undefined') return;
|
||||
try {
|
||||
indexedDB.deleteDatabase(DB_NAME);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
onFullSignOut(clearCompanyCalendarCache);
|
||||
|
||||
/** Drop one login's cached feeds (keys start with its address). */
|
||||
export async function forgetCompanyCalendarsOf(username: string): Promise<void> {
|
||||
const prefix = `${username}|`;
|
||||
for (const key of [...memory.keys()]) if (key.startsWith(prefix)) memory.delete(key);
|
||||
await idbDelete((key) => key.startsWith(prefix));
|
||||
}
|
||||
|
||||
// Signing one account out takes its cached feeds along.
|
||||
onBeforeSignOut(async (accountId) => {
|
||||
if (accountId === 'all') return;
|
||||
const username = useAccountStore.getState().getAccountById(accountId)?.username;
|
||||
if (username) await forgetCompanyCalendarsOf(username);
|
||||
});
|
||||
|
||||
/** The feed is gone for this user (removed, or no longer in their scope). */
|
||||
class FeedGoneError extends Error {
|
||||
constructor(feedId: string) {
|
||||
super(`Company calendar ${feedId} is gone`);
|
||||
this.name = 'FeedGoneError';
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256(text: string): Promise<string> {
|
||||
const subtle = globalThis.crypto?.subtle;
|
||||
if (subtle) {
|
||||
const digest = await subtle.digest('SHA-256', new TextEncoder().encode(text));
|
||||
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
// No WebCrypto (an http origin): a plain string hash still tells a change.
|
||||
let h = 0;
|
||||
for (let i = 0; i < text.length; i++) h = (Math.imul(31, h) + text.charCodeAt(i)) | 0;
|
||||
return `s${text.length}-${h}`;
|
||||
}
|
||||
|
||||
/** The feed text: by id through the server in full Bulwark, from its address in Lite. */
|
||||
async function fetchFeedText(feed: CompanyFeed): Promise<string> {
|
||||
const response = IS_LITE
|
||||
? await fetch(feed.url, { headers: { Accept: 'text/calendar, application/ics, text/plain, */*' }, credentials: 'omit' })
|
||||
: await apiFetch('/api/fetch-ical', {
|
||||
method: 'POST',
|
||||
// The server resolves the feed against the active account's scopes:
|
||||
// name its sign-in slot, or it picks the first signed-in account.
|
||||
headers: { 'Content-Type': 'application/json', ...getActiveAccountSlotHeaders() },
|
||||
body: JSON.stringify({ feedId: feed.id }),
|
||||
});
|
||||
if (response.status === 404 || response.status === 410) throw new FeedGoneError(feed.id);
|
||||
if (!response.ok) throw new Error(`Company calendar ${feed.id}: ${response.status}`);
|
||||
return response.text();
|
||||
}
|
||||
|
||||
type FeedClient = Pick<IJMAPClient, 'uploadBlob' | 'parseCalendarEvents' | 'getCalendarsAccountId'>;
|
||||
|
||||
const inFlight = new Map<string, Promise<Partial<CalendarEvent>[]>>();
|
||||
|
||||
/**
|
||||
* The parsed events of one feed for one account: from the cache while it is
|
||||
* fresh, else fetched, and parsed again only when the text changed. A feed
|
||||
* that can't be fetched keeps what was cached. Never rejects.
|
||||
*/
|
||||
export function loadFeedEvents(client: FeedClient, accountKey: string, feed: CompanyFeed, now = Date.now()): Promise<Partial<CalendarEvent>[]> {
|
||||
const key = `${accountKey}|${feed.id}`;
|
||||
const running = inFlight.get(key);
|
||||
if (running) return running;
|
||||
const work = (async () => {
|
||||
const cached = memory.get(key) ?? (await idbGet(key));
|
||||
if (cached) memory.set(key, cached);
|
||||
if (cached && now - cached.fetchedAt < feed.refreshMinutes * 60_000) return cached.events;
|
||||
try {
|
||||
const text = await fetchFeedText(feed);
|
||||
const hash = await sha256(text);
|
||||
let events = cached?.events ?? [];
|
||||
if (!cached || cached.hash !== hash) {
|
||||
const accountId = client.getCalendarsAccountId();
|
||||
const file = new File([text], 'company-calendar.ics', { type: 'text/calendar' });
|
||||
// The XHR path (progress callback): a 429 is this upload's alone.
|
||||
const uploaded = await client.uploadBlob(file, { accountId, onProgress: () => {} });
|
||||
events = await client.parseCalendarEvents(accountId, uploaded.blobId);
|
||||
}
|
||||
const entry: FeedCacheEntry = { hash, fetchedAt: now, events };
|
||||
memory.set(key, entry);
|
||||
await idbPut(key, entry);
|
||||
return events;
|
||||
} catch (error) {
|
||||
// A feed the server no longer gives this user takes its events along;
|
||||
// one that is only down keeps showing what it had.
|
||||
if (error instanceof Error && error.name === 'FeedGoneError') {
|
||||
memory.delete(key);
|
||||
await idbDelete((k) => k === key);
|
||||
return [];
|
||||
}
|
||||
debug.warn('calendar', 'Company calendar could not be refreshed', { feed: feed.id, error });
|
||||
return cached?.events ?? [];
|
||||
}
|
||||
})().finally(() => inFlight.delete(key));
|
||||
inFlight.set(key, work);
|
||||
return work;
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
export function resetCompanyCalendarMemory(): void {
|
||||
memory.clear();
|
||||
inFlight.clear();
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* "Suggest times" in the event editor: the next slots in which nobody whose
|
||||
* calendar Bulwark could check is busy.
|
||||
*
|
||||
* The search runs over the organiser's working days and day hours (the
|
||||
* calendar settings of #1164), in the effective time zone, on a 30-minute
|
||||
* grid. The hours are used even when the calendar doesn't limit the time
|
||||
* grids to them: a suggestion at three in the morning helps nobody.
|
||||
*/
|
||||
|
||||
import type { BusyPeriod } from '@/lib/jmap/types';
|
||||
import { fromWallClock, getWallClock } from '@/lib/timezone';
|
||||
import { resolveDisplayHours } from '@/lib/calendar-display-range';
|
||||
|
||||
export const SEARCH_WORKING_DAYS = 10;
|
||||
export const MAX_SUGGESTIONS = 5;
|
||||
export const GRID_MINUTES = 30;
|
||||
const DEFAULT_WORKING_DAYS = [1, 2, 3, 4, 5];
|
||||
const DEFAULT_HOURS = { start: 8, end: 20 };
|
||||
|
||||
export interface SlotSearchOptions {
|
||||
/** The earliest moment a slot may start. */
|
||||
now: Date;
|
||||
/** The meeting's length. */
|
||||
durationMs: number;
|
||||
/** The effective IANA time zone the hours and days are read in. */
|
||||
timeZone: string;
|
||||
/** Weekdays as Date.getDay numbers (0 = Sunday); anything unusable means Monday to Friday. */
|
||||
workingDays: unknown;
|
||||
dayStartHour: unknown;
|
||||
dayEndHour: unknown;
|
||||
/** Busy time of everyone who was checked, merged or not. */
|
||||
busy: BusyPeriod[];
|
||||
maxSlots?: number;
|
||||
workingDayCount?: number;
|
||||
}
|
||||
|
||||
export interface Slot {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
interface Day {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
}
|
||||
|
||||
function workingDaySet(value: unknown): Set<number> {
|
||||
const days = Array.isArray(value) ? value.filter((d): d is number => Number.isInteger(d) && d >= 0 && d <= 6) : [];
|
||||
return new Set(days.length > 0 ? days : DEFAULT_WORKING_DAYS);
|
||||
}
|
||||
|
||||
/** The day hours to search in minutes from midnight: the user's, else 8 to 20. */
|
||||
export function searchHours(startHour: unknown, endHour: unknown): { startMinutes: number; endMinutes: number } {
|
||||
const hours = resolveDisplayHours(true, startHour, endHour);
|
||||
if (!hours.restricted && !(startHour === 0 && endHour === 24)) {
|
||||
return { startMinutes: DEFAULT_HOURS.start * 60, endMinutes: DEFAULT_HOURS.end * 60 };
|
||||
}
|
||||
return { startMinutes: hours.startMinutes, endMinutes: hours.endMinutes };
|
||||
}
|
||||
|
||||
function nextDay(d: Day): Day {
|
||||
const date = new Date(Date.UTC(d.year, d.month - 1, d.day + 1));
|
||||
return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1, day: date.getUTCDate() };
|
||||
}
|
||||
|
||||
function weekday(d: Day): number {
|
||||
return new Date(Date.UTC(d.year, d.month - 1, d.day)).getUTCDay();
|
||||
}
|
||||
|
||||
function at(d: Day, minutes: number, timeZone: string): Date {
|
||||
// 24:00 is midnight of the next day.
|
||||
const day = minutes >= 1440 ? nextDay(d) : d;
|
||||
const m = minutes % 1440;
|
||||
return fromWallClock({ ...day, hour: Math.floor(m / 60), minute: m % 60, second: 0 }, timeZone);
|
||||
}
|
||||
|
||||
/** The working days the search covers, from today on, in `timeZone`. */
|
||||
function searchDays(now: Date, timeZone: string, workingDays: Set<number>, count: number): Day[] {
|
||||
const wall = getWallClock(now, timeZone);
|
||||
let d: Day = { year: wall.year, month: wall.month, day: wall.day };
|
||||
const days: Day[] = [];
|
||||
// At most a year of days, in case the set is somehow empty.
|
||||
for (let i = 0; i < 366 && days.length < count; i++) {
|
||||
if (workingDays.has(weekday(d))) days.push(d);
|
||||
d = nextDay(d);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
/**
|
||||
* The span one availability call per person has to cover: from now to the
|
||||
* end of the last working day searched.
|
||||
*/
|
||||
export function searchWindow(options: Pick<SlotSearchOptions, 'now' | 'timeZone' | 'workingDays' | 'dayStartHour' | 'dayEndHour' | 'workingDayCount'>): { start: Date; end: Date } {
|
||||
const days = searchDays(options.now, options.timeZone, workingDaySet(options.workingDays), options.workingDayCount ?? SEARCH_WORKING_DAYS);
|
||||
const last = days[days.length - 1];
|
||||
const { endMinutes } = searchHours(options.dayStartHour, options.dayEndHour);
|
||||
return { start: options.now, end: last ? at(last, endMinutes, options.timeZone) : options.now };
|
||||
}
|
||||
|
||||
function overlaps(busy: Array<{ start: number; end: number }>, start: number, end: number): boolean {
|
||||
for (const period of busy) {
|
||||
if (period.start < end && period.end > start) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Up to `maxSlots` meeting slots of `durationMs` inside the working hours of
|
||||
* the next working days, starting on the grid, that overlap no busy period.
|
||||
* Tentative time counts as busy: a suggestion should not ask anyone to drop
|
||||
* something.
|
||||
*/
|
||||
export function findFreeSlots(options: SlotSearchOptions): Slot[] {
|
||||
const max = options.maxSlots ?? MAX_SUGGESTIONS;
|
||||
if (!(options.durationMs > 0)) return [];
|
||||
const { startMinutes, endMinutes } = searchHours(options.dayStartHour, options.dayEndHour);
|
||||
const days = searchDays(options.now, options.timeZone, workingDaySet(options.workingDays), options.workingDayCount ?? SEARCH_WORKING_DAYS);
|
||||
const busy = options.busy
|
||||
.map((p) => ({ start: Date.parse(p.utcStart), end: Date.parse(p.utcEnd) }))
|
||||
.filter((p) => Number.isFinite(p.start) && Number.isFinite(p.end) && p.end > p.start);
|
||||
const nowMs = options.now.getTime();
|
||||
const slots: Slot[] = [];
|
||||
for (const day of days) {
|
||||
const dayEnd = at(day, endMinutes, options.timeZone).getTime();
|
||||
for (let minutes = startMinutes; minutes < endMinutes; minutes += GRID_MINUTES) {
|
||||
const start = at(day, minutes, options.timeZone).getTime();
|
||||
const end = start + options.durationMs;
|
||||
if (end > dayEnd) break;
|
||||
if (start < nowMs) continue;
|
||||
if (overlaps(busy, start, end)) continue;
|
||||
slots.push({ start: new Date(start), end: new Date(end) });
|
||||
if (slots.length >= max) return slots;
|
||||
}
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Sharing the user's free/busy with a group.
|
||||
*
|
||||
* Stalwart answers Principal/getAvailability only from calendars shared with
|
||||
* the person asking (mayReadFreeBusy or mayReadItems, to them or a group they
|
||||
* are in), so on a fresh server "Suggest times" sees nobody busy
|
||||
* (local-data/enterprise-pack-facts.md part 6 §1). Two ways to fix that:
|
||||
*
|
||||
* - the admin names a group in organisation.calendars.freeBusyGroup, and
|
||||
* Bulwark shares each user's default calendar with it at sign-in;
|
||||
* - a user shares their own with one click (Calendar settings, the Find a
|
||||
* time note).
|
||||
*
|
||||
* Both only add: an existing share of the group keeps its rights, with
|
||||
* mayReadFreeBusy added when it lacked it, and no other share is touched.
|
||||
* The share is an ordinary one, so it shows in the calendar's sharing list.
|
||||
*/
|
||||
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { Calendar, CalendarRights } from '@/lib/jmap/types';
|
||||
import { CALENDAR_PRESETS } from '@/lib/jmap/share-rights';
|
||||
|
||||
type ShareClient = Pick<IJMAPClient, 'getAllCalendars' | 'setCalendarShare' | 'getSharedAccounts' | 'getAccountInfo' | 'findPrincipalByEmail'>;
|
||||
|
||||
export type FreeBusyShareResult = 'shared' | 'already' | 'no-calendar' | 'no-group';
|
||||
|
||||
/** The user's own default calendar: the server's default, else the first event calendar. */
|
||||
export function ownDefaultCalendar(calendars: Calendar[]): Calendar | null {
|
||||
const own = calendars.filter((c) => !c.isShared && !c.isTasksOnly);
|
||||
return own.find((c) => c.isDefault) ?? own[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The principal id of a group address: the group account in the session
|
||||
* (Stalwart uses the account id as the principal id), else a directory
|
||||
* lookup. Null when neither knows it.
|
||||
*/
|
||||
export async function groupPrincipalId(client: ShareClient, address: string): Promise<string | null> {
|
||||
const wanted = address.trim().toLowerCase();
|
||||
if (!wanted) return null;
|
||||
const account = (typeof client.getSharedAccounts === 'function' ? client.getSharedAccounts() : [])
|
||||
.find((a) => !a.isPrimary && a.name.trim().toLowerCase() === wanted && client.getAccountInfo?.(a.id)?.isPersonal === false);
|
||||
if (account) return account.id;
|
||||
if (!client.findPrincipalByEmail) return null;
|
||||
const found = await client.findPrincipalByEmail(wanted).catch(() => null);
|
||||
const principal = found?.principal;
|
||||
// Only a principal that says it is a group: an address typed by mistake
|
||||
// must not share everyone's free/busy with one person.
|
||||
return principal?.type === 'group' ? principal.id : null;
|
||||
}
|
||||
|
||||
/** Whether `calendar` already lets the principal see its free/busy. */
|
||||
export function sharesFreeBusyWith(calendar: Pick<Calendar, 'shareWith'>, principalId: string): boolean {
|
||||
const rights = calendar.shareWith?.[principalId];
|
||||
return !!rights && (rights.mayReadFreeBusy || rights.mayReadItems);
|
||||
}
|
||||
|
||||
/** The rights to write for the principal: what it has plus mayReadFreeBusy. */
|
||||
export function withFreeBusy(existing: CalendarRights | undefined): CalendarRights {
|
||||
return existing ? { ...existing, mayReadFreeBusy: true } : { ...CALENDAR_PRESETS.freeBusy };
|
||||
}
|
||||
|
||||
/** Share the user's default calendar's free/busy with the group at `address`. */
|
||||
export async function ensureFreeBusyShare(client: ShareClient, address: string): Promise<FreeBusyShareResult> {
|
||||
const principalId = await groupPrincipalId(client, address);
|
||||
if (!principalId) return 'no-group';
|
||||
const calendar = ownDefaultCalendar(await client.getAllCalendars());
|
||||
if (!calendar) return 'no-calendar';
|
||||
if (sharesFreeBusyWith(calendar, principalId)) return 'already';
|
||||
await client.setCalendarShare(calendar.originalId ?? calendar.id, principalId, withFreeBusy(calendar.shareWith?.[principalId]));
|
||||
return 'shared';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user's default calendar shares free/busy with the group, or
|
||||
* null when that can't be told (no calendar, unknown group).
|
||||
*/
|
||||
export async function freeBusyShareState(client: ShareClient, address: string): Promise<boolean | null> {
|
||||
const principalId = await groupPrincipalId(client, address);
|
||||
if (!principalId) return null;
|
||||
const calendar = ownDefaultCalendar(await client.getAllCalendars());
|
||||
return calendar ? sharesFreeBusyWith(calendar, principalId) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The groups a user can share with from the button: the admin's group when
|
||||
* one is set, else the group accounts in the session.
|
||||
*/
|
||||
export function freeBusyShareGroups(client: Pick<IJMAPClient, 'getSharedAccounts' | 'getAccountInfo'> | null, adminGroup: string): string[] {
|
||||
if (adminGroup) return [adminGroup.toLowerCase()];
|
||||
if (!client || typeof client.getSharedAccounts !== 'function') return [];
|
||||
return client.getSharedAccounts()
|
||||
.filter((a) => !a.isPrimary && a.name.includes('@') && client.getAccountInfo?.(a.id)?.isPersonal === false)
|
||||
.map((a) => a.name.trim().toLowerCase())
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Once per page load and account: the admin's share is applied at sign-in,
|
||||
// and a user who removes it later gets it back at the next one.
|
||||
const applied = new Set<string>();
|
||||
|
||||
/**
|
||||
* Apply the admin's free/busy group to the signed-in account. Never throws;
|
||||
* a failure is tried again on the next sign-in or page load.
|
||||
*/
|
||||
export async function applyAdminFreeBusyShare(client: ShareClient & Pick<IJMAPClient, 'getAccountId'>, group: string): Promise<FreeBusyShareResult | null> {
|
||||
if (!group) return null;
|
||||
const key = `${client.getAccountId()}|${group.toLowerCase()}`;
|
||||
if (applied.has(key)) return null;
|
||||
applied.add(key);
|
||||
try {
|
||||
return await ensureFreeBusyShare(client, group);
|
||||
} catch {
|
||||
applied.delete(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
export function resetAppliedFreeBusyShares(): void {
|
||||
applied.clear();
|
||||
}
|
||||
@@ -629,6 +629,8 @@ export interface IJMAPClient {
|
||||
listPrincipals?(targetAccountId?: string, options?: { throwOnError?: boolean }): Promise<PrincipalListing>;
|
||||
/** Principal type by id ("individual", "group"); null when unknown. Optional (demo client). */
|
||||
getPrincipalTypes?(ids: string[]): Promise<Record<string, string> | null>;
|
||||
/** The principals behind accounts (who owns a shared calendar); missing ids are left out. Optional (demo client). */
|
||||
getAccountOwners?(ids: string[]): Promise<Record<string, Principal>>;
|
||||
// Bulwark data in Inbox metadata (lib/jmap/bulwark-metadata.ts). Optional (demo client).
|
||||
supportsBulwarkMetadata?(accountId?: string): boolean;
|
||||
getBulwarkMetadata?(accountId?: string, options?: { private?: boolean }): Promise<Record<string, unknown> | null>;
|
||||
|
||||
@@ -7313,6 +7313,30 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The principals behind accounts, by account id: who owns a calendar shared
|
||||
* with the user, with their name and address. Principal/get answers for
|
||||
* accounts the user can access even with directory queries off. A failed
|
||||
* request gives an empty answer.
|
||||
*/
|
||||
async getAccountOwners(ids: string[]): Promise<Record<string, Principal>> {
|
||||
if (!this.supportsPrincipals() || ids.length === 0) return {};
|
||||
try {
|
||||
const response = await this.request([
|
||||
["Principal/get", { accountId: this.accountId, ids, properties: ["id", "type", "name", "description", "email"] }, "0"],
|
||||
], this.principalsUsing());
|
||||
const [method, result] = response.methodResponses?.[0] ?? [];
|
||||
if (method !== "Principal/get") return {};
|
||||
const owners: Record<string, Principal> = {};
|
||||
for (const principal of (result as { list?: Principal[] }).list ?? []) {
|
||||
if (principal.id) owners[principal.id] = principal;
|
||||
}
|
||||
return owners;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accounts whose metadata request failed, with when: not asked again for
|
||||
* ten minutes. A dropped connection (the pre-release 1.0 build) looks the
|
||||
|
||||
+25
-1
@@ -25,6 +25,14 @@ export const AVAILABILITY_TTL_MS = 3 * 60 * 1000;
|
||||
*/
|
||||
export const RATE_LIMIT_BACKOFF_MS = 60 * 1000;
|
||||
|
||||
/**
|
||||
* Stalwart 1.0 allows this many availability calls per account a minute
|
||||
* (x:Calendar.availabilityRateLimit, the default). 0.16 has no limit; counting
|
||||
* there costs nothing.
|
||||
*/
|
||||
export const AVAILABILITY_CALLS_PER_MINUTE = 100;
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
|
||||
const access = new WeakMap<IJMAPClient, boolean>();
|
||||
const pendingChecks = new WeakMap<IJMAPClient, Promise<boolean>>();
|
||||
|
||||
@@ -66,6 +74,8 @@ interface CachedLookup {
|
||||
interface LookupState {
|
||||
cache: Map<string, CachedLookup>;
|
||||
backoffUntil: number;
|
||||
/** When each call of the last minute went out, oldest first. */
|
||||
sent: number[];
|
||||
}
|
||||
|
||||
const lookups = new WeakMap<IJMAPClient, LookupState>();
|
||||
@@ -73,12 +83,25 @@ const lookups = new WeakMap<IJMAPClient, LookupState>();
|
||||
function lookupStateOf(client: IJMAPClient): LookupState {
|
||||
let state = lookups.get(client);
|
||||
if (!state) {
|
||||
state = { cache: new Map(), backoffUntil: 0 };
|
||||
state = { cache: new Map(), backoffUntil: 0, sent: [] };
|
||||
lookups.set(client, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many more availability calls this tab can make within the server's
|
||||
* limit right now: none while it backs off after a refusal. The limit is per
|
||||
* account and other tabs and devices spend from it too, so this is an upper
|
||||
* bound; a search that needs more than this is not started.
|
||||
*/
|
||||
export function availabilityCallsLeft(client: IJMAPClient, now = Date.now()): number {
|
||||
const state = lookupStateOf(client);
|
||||
if (now < state.backoffUntil) return 0;
|
||||
state.sent = state.sent.filter((at) => now - at < MINUTE_MS);
|
||||
return Math.max(0, AVAILABILITY_CALLS_PER_MINUTE - state.sent.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* The window a lookup asks the server for: the local days from `start` to
|
||||
* `end`, so that moving an event within its day is answered from the cache.
|
||||
@@ -124,6 +147,7 @@ export async function lookUpAvailability(
|
||||
if (missing.length > 0 && now < state.backoffUntil) {
|
||||
for (const id of missing) results.set(id, Promise.resolve({ error: "rateLimit" }));
|
||||
} else if (missing.length > 0) {
|
||||
for (let i = 0; i < missing.length; i++) state.sent.push(now);
|
||||
const batch = client.getPrincipalsAvailability!(missing, days.start, days.end)
|
||||
.catch((): Record<string, PrincipalAvailability> => ({}));
|
||||
for (const id of missing) {
|
||||
|
||||
@@ -676,6 +676,9 @@ export interface Calendar {
|
||||
// hidden from the event calendar UI while remaining available to the tasks
|
||||
// view (#761). Undefined means "not determined" (treated as not tasks-only).
|
||||
isTasksOnly?: boolean;
|
||||
// A company calendar the admin set (lib/company-calendars.ts): a read-only
|
||||
// calendar that exists only in the browser.
|
||||
isCompany?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -778,6 +781,11 @@ export interface CalendarParticipant {
|
||||
scheduleStatus: string[] | null;
|
||||
scheduleUpdated: string | null;
|
||||
invitedBy: string | null;
|
||||
/**
|
||||
* Who answered for this participant (RFC 5545 SENT-BY), as a
|
||||
* `mailto:` address. Set when a delegate answers for the calendar's owner.
|
||||
*/
|
||||
sentBy?: string | null;
|
||||
delegatedTo: Record<string, boolean> | null;
|
||||
delegatedFrom: Record<string, boolean> | null;
|
||||
memberOf: Record<string, boolean> | null;
|
||||
|
||||
@@ -687,9 +687,15 @@ export function buildOccurrenceRsvpPatch(
|
||||
participantId: string,
|
||||
status: CalendarParticipant['participationStatus'],
|
||||
options: OverridePatchOptions = {},
|
||||
sentBy?: string | null,
|
||||
): Partial<CalendarEvent> | null {
|
||||
const participant = occurrence.participants?.[participantId];
|
||||
if (!participant) return null;
|
||||
// sentBy: who answers for the participant (lib/calendar-delegate.ts); null
|
||||
// drops an old one, left out keeps what is there.
|
||||
const answered: CalendarParticipant = { ...participant, participationStatus: status };
|
||||
if (sentBy) answered.sentBy = sentBy;
|
||||
else if (sentBy === null) delete answered.sentBy;
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (occurrence.sequence != null) patch.sequence = occurrence.sequence;
|
||||
if (options.seriesKeys && occurrence.organizerCalendarAddress) {
|
||||
@@ -697,7 +703,7 @@ export function buildOccurrenceRsvpPatch(
|
||||
}
|
||||
patch.participants = {
|
||||
...occurrence.participants,
|
||||
[participantId]: { ...participant, participationStatus: status },
|
||||
[participantId]: answered,
|
||||
};
|
||||
return withNewOverrideDetails(occurrence, patch as Partial<CalendarEvent>, options);
|
||||
}
|
||||
|
||||
+110
-10
@@ -10,7 +10,7 @@ import {
|
||||
type RequestInit as UndiciRequestInit,
|
||||
type Response as UndiciResponse,
|
||||
} from 'undici';
|
||||
import { isPrivateAddress } from './ip-ranges';
|
||||
import { isPrivateAddress, parseV6Groups } from './ip-ranges';
|
||||
|
||||
const BLOCKED_HOSTNAMES = new Set(['localhost']);
|
||||
const BLOCKED_HOSTNAME_SUFFIXES = ['.localhost', '.local', '.internal', '.arpa', '.localdomain'];
|
||||
@@ -29,12 +29,47 @@ function isBlockedHostname(hostname: string): boolean {
|
||||
return BLOCKED_HOSTNAME_SUFFIXES.some((suffix) => hostname.endsWith(suffix));
|
||||
}
|
||||
|
||||
/**
|
||||
* An intranet address: RFC 1918, CGNAT or an IPv6 unique local address,
|
||||
* as such or IPv4-mapped. Never loopback, link-local (cloud metadata lives
|
||||
* there), unspecified, multicast or the AWS metadata ULA.
|
||||
*/
|
||||
export function isIntranetAddress(ip: string): boolean {
|
||||
const host = normalizeHostname(ip);
|
||||
const v4 = (a: string) => /^10\./.test(a) || /^172\.(1[6-9]|2\d|3[0-1])\./.test(a) || /^192\.168\./.test(a)
|
||||
|| /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(a);
|
||||
const family = isIP(host);
|
||||
if (family === 4) return v4(host);
|
||||
if (family !== 6) return false;
|
||||
const g = parseV6Groups(host);
|
||||
if (!g) return false;
|
||||
if (g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff) {
|
||||
return v4(`${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`);
|
||||
}
|
||||
const awsMetadata = g[0] === 0xfd00 && g[1] === 0x0ec2 && g.slice(2, 7).every((x) => x === 0) && g[7] === 0x254;
|
||||
return (g[0] & 0xfe00) === 0xfc00 && !awsMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an admin-entered host names the intranet: an intranet address
|
||||
* literal, a name under a private suffix (.local, .internal, .localdomain)
|
||||
* or a single-label name. Public names and localhost never are, so a public
|
||||
* host whose DNS later points inside can't be used to reach it.
|
||||
*/
|
||||
export function isIntranetHostName(hostname: string): boolean {
|
||||
const host = normalizeHostname(hostname);
|
||||
if (!host || host === 'localhost' || host.endsWith('.localhost')) return false;
|
||||
if (isIP(host)) return isIntranetAddress(host);
|
||||
if (['.local', '.internal', '.localdomain'].some((suffix) => host.endsWith(suffix))) return true;
|
||||
return !host.includes('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous part of the guard: protocol, embedded credentials, blocked
|
||||
* hostnames / suffixes, and literal IP addresses. Returns the parsed URL when
|
||||
* those checks pass, `null` otherwise. Does NOT touch DNS.
|
||||
*/
|
||||
function parseAllowedUrl(urlString: string): URL | null {
|
||||
function parseAllowedUrl(urlString: string, privateHost?: string): URL | null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(urlString);
|
||||
@@ -46,6 +81,7 @@ function parseAllowedUrl(urlString: string): URL | null {
|
||||
if (url.username || url.password) return null;
|
||||
|
||||
const hostname = normalizeHostname(url.hostname);
|
||||
if (privateHost && hostname === privateHost && isIntranetHostName(hostname)) return url;
|
||||
if (isBlockedHostname(hostname)) return null;
|
||||
if (isBlockedIpAddress(hostname)) return null;
|
||||
|
||||
@@ -213,9 +249,57 @@ export function envProxyFor(url: URL): URL | null {
|
||||
}
|
||||
}
|
||||
|
||||
function lookupChecked(hostname: string): Promise<string> {
|
||||
/**
|
||||
* guardedLookup, except that `privateHost` may resolve to an intranet
|
||||
* address (isIntranetAddress): a host on the intranet the admin entered
|
||||
* themselves (company calendars). Loopback, link-local and metadata
|
||||
* addresses stay refused for it too; every other name, redirect targets
|
||||
* included, is checked as always; and the address is still the one the
|
||||
* socket connects to.
|
||||
*/
|
||||
function lookupAllowing(privateHost: string): typeof guardedLookup {
|
||||
return (hostname, options, callback) => {
|
||||
if (normalizeHostname(hostname) !== privateHost) {
|
||||
guardedLookup(hostname, options, callback);
|
||||
return;
|
||||
}
|
||||
const cb = (typeof options === 'function' ? options : callback) as LookupCallback;
|
||||
const opts: LookupOptions = typeof options === 'function' ? {} : typeof options === 'number' ? { family: options } : (options ?? {});
|
||||
lookup(normalizeHostname(hostname), { family: opts.family, hints: opts.hints, all: true, verbatim: true })
|
||||
.then((records) => {
|
||||
if (records.length === 0) {
|
||||
const err: NodeJS.ErrnoException = new Error(`getaddrinfo ENOTFOUND ${hostname}`);
|
||||
err.code = 'ENOTFOUND';
|
||||
cb(err);
|
||||
return;
|
||||
}
|
||||
const refused = records.find((record) => isBlockedIpAddress(record.address) && !isIntranetAddress(record.address));
|
||||
if (refused) {
|
||||
cb(new BlockedAddressError(hostname, refused.address));
|
||||
return;
|
||||
}
|
||||
if (opts.all) cb(null, records);
|
||||
else cb(null, records[0].address, records[0].family);
|
||||
})
|
||||
.catch((err: NodeJS.ErrnoException) => cb(err));
|
||||
};
|
||||
}
|
||||
|
||||
const privateHostDispatchers = new Map<string, Dispatcher>();
|
||||
|
||||
function getPrivateHostDispatcher(privateHost: string): Dispatcher {
|
||||
let dispatcher = privateHostDispatchers.get(privateHost);
|
||||
if (!dispatcher) {
|
||||
dispatcher = new Agent({ connect: { lookup: lookupAllowing(privateHost) as unknown as LookupFunction } });
|
||||
privateHostDispatchers.set(privateHost, dispatcher);
|
||||
}
|
||||
return dispatcher;
|
||||
}
|
||||
|
||||
function lookupChecked(hostname: string, privateHost?: string): Promise<string> {
|
||||
const look = privateHost ? lookupAllowing(privateHost) : guardedLookup;
|
||||
return new Promise((resolve, reject) => {
|
||||
guardedLookup(hostname, {}, (err, address) => (err ? reject(err) : resolve(address as string)));
|
||||
look(hostname, {}, (err, address) => (err ? reject(err) : resolve(address as string)));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -229,8 +313,9 @@ const proxiedDispatchers = new Map<string, Dispatcher>();
|
||||
* address. The request keeps its Host header, and TLS inside the tunnel uses
|
||||
* the hostname for SNI and the certificate check.
|
||||
*/
|
||||
function getProxiedPublicDispatcher(proxy: URL): Dispatcher {
|
||||
let dispatcher = proxiedDispatchers.get(proxy.href);
|
||||
function getProxiedPublicDispatcher(proxy: URL, privateHost?: string): Dispatcher {
|
||||
const cacheKey = privateHost ? `${proxy.href}|${privateHost}` : proxy.href;
|
||||
let dispatcher = proxiedDispatchers.get(cacheKey);
|
||||
if (dispatcher) return dispatcher;
|
||||
|
||||
const proxyClient = new Client(proxy.origin);
|
||||
@@ -241,7 +326,7 @@ function getProxiedPublicDispatcher(proxy: URL): Dispatcher {
|
||||
const connect: buildConnector.connector = (opts, callback) => {
|
||||
const hostname = normalizeHostname(opts.hostname);
|
||||
const port = Number(opts.port) || (opts.protocol === 'https:' ? 443 : 80);
|
||||
lookupChecked(hostname)
|
||||
lookupChecked(hostname, privateHost)
|
||||
.then(async (address) => {
|
||||
const authority = `${isIP(address) === 6 ? `[${address}]` : address}:${port}`;
|
||||
const tunnel = await proxyClient.connect({
|
||||
@@ -265,7 +350,7 @@ function getProxiedPublicDispatcher(proxy: URL): Dispatcher {
|
||||
.catch((err: Error) => callback(err, null));
|
||||
};
|
||||
dispatcher = new Agent({ connect });
|
||||
proxiedDispatchers.set(proxy.href, dispatcher);
|
||||
proxiedDispatchers.set(cacheKey, dispatcher);
|
||||
return dispatcher;
|
||||
}
|
||||
|
||||
@@ -296,18 +381,33 @@ export type PublicFetchResponse = UndiciResponse;
|
||||
export async function fetchPublicUrl(
|
||||
url: string,
|
||||
init: Omit<UndiciRequestInit, 'dispatcher' | 'redirect'> = {},
|
||||
options: {
|
||||
/**
|
||||
* One hostname on the intranet (isIntranetHostName) that may resolve to
|
||||
* an intranet address: only for an address the admin entered (company
|
||||
* calendars). It must equal the URL's hostname to make any difference;
|
||||
* a public name here changes nothing.
|
||||
*/
|
||||
allowPrivateHost?: string;
|
||||
} = {},
|
||||
): Promise<PublicFetchResponse> {
|
||||
const parsed = parseAllowedUrl(url);
|
||||
const privateHost = options.allowPrivateHost && isIntranetHostName(options.allowPrivateHost)
|
||||
? normalizeHostname(options.allowPrivateHost)
|
||||
: undefined;
|
||||
const parsed = parseAllowedUrl(url, privateHost);
|
||||
if (!parsed) {
|
||||
throw new DisallowedUrlError(url);
|
||||
}
|
||||
const proxy = envProxyFor(parsed);
|
||||
const exempt = privateHost && normalizeHostname(parsed.hostname) === privateHost ? privateHost : undefined;
|
||||
|
||||
try {
|
||||
return await undiciFetch(url, {
|
||||
...init,
|
||||
redirect: 'manual',
|
||||
dispatcher: proxy ? getProxiedPublicDispatcher(proxy) : getPublicDispatcher(),
|
||||
dispatcher: proxy
|
||||
? getProxiedPublicDispatcher(proxy, exempt)
|
||||
: exempt ? getPrivateHostDispatcher(exempt) : getPublicDispatcher(),
|
||||
});
|
||||
} catch (err) {
|
||||
const blocked = findBlockedCause(err);
|
||||
|
||||
@@ -68,6 +68,13 @@ export function purgeSignedOutData(): void {
|
||||
}
|
||||
// Attachment bytes a plugin staged for an upload that never started.
|
||||
void fileStorage.clearFiles().catch(() => {});
|
||||
// Company calendar events (lib/company-calendars.ts), also when the
|
||||
// calendar was never opened in this tab.
|
||||
try {
|
||||
window.indexedDB?.deleteDatabase('bulwark-company-calendars');
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
for (const cleanup of signedOutCleanups) {
|
||||
try {
|
||||
cleanup();
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Out-of-office blocks the calendar.
|
||||
*
|
||||
* While an out-of-office reply with a start and an end is on, Bulwark keeps
|
||||
* one busy event for it in the account's default calendar. The event is
|
||||
* found again by a fixed UID per account (`bulwark-ooo-<accountId>`; 0.16
|
||||
* rejects vendor properties, a UID survives every CalDAV client). It is
|
||||
* timed, or all-day when both ends are midnight in the user's time zone;
|
||||
* busy, public, without participants, and never sends invitations.
|
||||
*
|
||||
* The event is Bulwark's while its times are the ones Bulwark last wrote
|
||||
* (kept in the settings, per account) or the reply's dates before the change
|
||||
* at hand. That second rule works on any device and for anyone who changes
|
||||
* the reply (a delegate, the owner), also where the settings don't sync
|
||||
* (Lite, sync off). The event also carries the keyword OOO_KEYWORD (a
|
||||
* CATEGORIES value, which 0.16 and 1.0 both keep): on a device with no
|
||||
* record, a marked event found when the reply is loaded counts as Bulwark's.
|
||||
* When none of these hold, someone edited it, and Bulwark leaves it alone. Switching the reply or the block off removes an
|
||||
* event that hasn't started and ends one that has at once; a reply that
|
||||
* simply ran out leaves its past event in place. An event deleted elsewhere
|
||||
* is not put back until the reply's dates change.
|
||||
*
|
||||
* The sync runs after a save (the settings page and plugins both save
|
||||
* through stores/vacation-store.ts) when the switch, the dates or the
|
||||
* subject changed, debounced, one at a time, and once per page load and
|
||||
* account when the reply is loaded (it may have been set in another
|
||||
* client). A reply another client set up under a Sieve script of its own is
|
||||
* invisible to VacationResponse, so it can't be followed.
|
||||
*/
|
||||
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
|
||||
import { fromWallClock, getEffectiveTimeZone, getWallClock, parseLocalDateTime } from '@/lib/timezone';
|
||||
import { parseDurationSeconds } from '@/lib/calendar-event-normalization';
|
||||
import { parseScript } from '@/lib/sieve/parser';
|
||||
import { VACATION_SCRIPT_NAME } from '@/lib/sieve/generator';
|
||||
import { supportsInclude } from '@/lib/filters/account-filters';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { debug } from '@/lib/debug';
|
||||
|
||||
export const OOO_UID_PREFIX = 'bulwark-ooo-';
|
||||
export const SYNC_DEBOUNCE_MS = 1500;
|
||||
|
||||
/** The keyword (iCalendar CATEGORIES) on the busy event. */
|
||||
export const OOO_KEYWORD = 'bulwark-out-of-office';
|
||||
|
||||
export function oooUid(accountId: string): string {
|
||||
return `${OOO_UID_PREFIX}${accountId}`;
|
||||
}
|
||||
|
||||
/** What the vacation response says, as far as the block cares. */
|
||||
export interface ReplyState {
|
||||
isEnabled: boolean;
|
||||
fromDate: string | null;
|
||||
toDate: string | null;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
/** The times Bulwark last wrote to the event, as ISO instants. */
|
||||
export interface WrittenBlock {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export type BlockDecision =
|
||||
| { action: 'none' }
|
||||
| { action: 'create'; start: Date; end: Date }
|
||||
| { action: 'update'; start: Date; end: Date }
|
||||
| { action: 'destroy' }
|
||||
| { action: 'end-now'; end: Date };
|
||||
|
||||
export interface DecisionInput {
|
||||
/** Block my calendar (the setting, or the organisation's lock). */
|
||||
block: boolean;
|
||||
reply: ReplyState;
|
||||
/** The event with our UID, as instants; null when there is none. */
|
||||
event: { start: Date; end: Date } | null;
|
||||
last: WrittenBlock | null;
|
||||
/** The reply's dates before the change being synced; null when unknown (a load). */
|
||||
previous?: Pick<ReplyState, 'fromDate' | 'toDate'> | null;
|
||||
/** The event carries OOO_KEYWORD. */
|
||||
marked?: boolean;
|
||||
now: Date;
|
||||
/** `save`: the reply or the block just changed here. `load`: the reply was read. */
|
||||
trigger: 'save' | 'load';
|
||||
}
|
||||
|
||||
const same = (a: Date, b: string | Date) => a.getTime() === (typeof b === 'string' ? Date.parse(b) : b.getTime());
|
||||
|
||||
/** What to do with the busy event, by the rules in the module comment. */
|
||||
export function decideBlock(input: DecisionInput): BlockDecision {
|
||||
const { reply, event, last, now } = input;
|
||||
const from = reply.fromDate ? new Date(reply.fromDate) : null;
|
||||
const to = reply.toDate ? new Date(reply.toDate) : null;
|
||||
const datesUsable = !!from && !!to && !Number.isNaN(from.getTime()) && !Number.isNaN(to.getTime()) && to > from;
|
||||
const wanted = input.block && reply.isEnabled && datesUsable;
|
||||
const ranOut = reply.isEnabled && !!to && to <= now;
|
||||
|
||||
if (event) {
|
||||
const matchesLast = !!last && same(event.start, last.start) && same(event.end, last.end);
|
||||
const matchesPrevious = !!input.previous?.fromDate && !!input.previous.toDate
|
||||
&& same(event.start, input.previous.fromDate) && same(event.end, input.previous.toDate);
|
||||
const markedOnLoad = !last && !!input.marked && input.trigger === 'load';
|
||||
if (!matchesLast && !matchesPrevious && !markedOnLoad) {
|
||||
// Set up or changed by someone else: left alone.
|
||||
return { action: 'none' };
|
||||
}
|
||||
if (wanted) {
|
||||
if (ranOut) return { action: 'none' };
|
||||
if (same(event.start, from!) && same(event.end, to!)) return { action: 'none' };
|
||||
return { action: 'update', start: from!, end: to! };
|
||||
}
|
||||
// A reply that ran out leaves its past event; so does any past event.
|
||||
if (ranOut || event.end <= now) return { action: 'none' };
|
||||
if (event.start > now) return { action: 'destroy' };
|
||||
return { action: 'end-now', end: now };
|
||||
}
|
||||
|
||||
if (!wanted || ranOut) return { action: 'none' };
|
||||
if (input.trigger === 'load' && last && same(from!, last.start) && same(to!, last.end)) {
|
||||
// It was there for these dates and someone deleted it: that sticks.
|
||||
return { action: 'none' };
|
||||
}
|
||||
return { action: 'create', start: from!, end: to! };
|
||||
}
|
||||
|
||||
/** ISO 8601 duration of a span, in days, hours and minutes. */
|
||||
export function isoDuration(ms: number): string {
|
||||
const totalMinutes = Math.max(0, Math.round(ms / 60000));
|
||||
const days = Math.floor(totalMinutes / 1440);
|
||||
const hours = Math.floor((totalMinutes % 1440) / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
const time = `${hours ? `${hours}H` : ''}${minutes ? `${minutes}M` : ''}`;
|
||||
if (!days && !time) return 'PT0S';
|
||||
return `P${days ? `${days}D` : ''}${time ? `T${time}` : ''}`;
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
/**
|
||||
* The event's time fields for a span: all-day when both ends are midnight in
|
||||
* `timeZone` (whole days stay whole for people in other zones), timed in the
|
||||
* zone otherwise.
|
||||
*/
|
||||
export function blockTimes(start: Date, end: Date, timeZone: string): Pick<CalendarEvent, 'start' | 'duration' | 'timeZone' | 'showWithoutTime'> {
|
||||
const a = getWallClock(start, timeZone);
|
||||
const b = getWallClock(end, timeZone);
|
||||
const midnight = (w: typeof a) => w.hour === 0 && w.minute === 0 && w.second === 0;
|
||||
if (midnight(a) && midnight(b)) {
|
||||
const days = Math.round((Date.UTC(b.year, b.month - 1, b.day) - Date.UTC(a.year, a.month - 1, a.day)) / 86400000);
|
||||
return { start: `${a.year}-${pad(a.month)}-${pad(a.day)}T00:00:00`, duration: `P${days}D`, timeZone: null, showWithoutTime: true };
|
||||
}
|
||||
return {
|
||||
start: `${a.year}-${pad(a.month)}-${pad(a.day)}T${pad(a.hour)}:${pad(a.minute)}:${pad(a.second)}`,
|
||||
duration: isoDuration(end.getTime() - start.getTime()),
|
||||
timeZone,
|
||||
showWithoutTime: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The instants an event covers, read the way blockTimes writes them. */
|
||||
export function eventSpan(event: Pick<CalendarEvent, 'start' | 'duration' | 'timeZone' | 'showWithoutTime'>, timeZone: string): { start: Date; end: Date } | null {
|
||||
const wall = parseLocalDateTime(event.start ?? '');
|
||||
if (!wall) return null;
|
||||
const zone = event.showWithoutTime ? timeZone : event.timeZone || timeZone;
|
||||
const start = fromWallClock(event.showWithoutTime ? { ...wall, hour: 0, minute: 0, second: 0 } : wall, zone);
|
||||
const seconds = parseDurationSeconds(event.duration ?? 'PT0S') ?? 0;
|
||||
if (event.showWithoutTime) {
|
||||
// Whole days in the zone, across a DST change too.
|
||||
const days = Math.round(seconds / 86400);
|
||||
const endDay = new Date(Date.UTC(wall.year, wall.month - 1, wall.day + days));
|
||||
const end = fromWallClock({ year: endDay.getUTCFullYear(), month: endDay.getUTCMonth() + 1, day: endDay.getUTCDate(), hour: 0, minute: 0, second: 0 }, zone);
|
||||
return { start, end };
|
||||
}
|
||||
return { start, end: new Date(start.getTime() + seconds * 1000) };
|
||||
}
|
||||
|
||||
/** The calendar of the account a block goes into: its default, if the user may add events there. */
|
||||
export function blockCalendar(calendars: Calendar[], accountId: string, primaryAccountId: string): Calendar | null {
|
||||
const own = calendars.filter((c) => !c.isTasksOnly && (accountId === primaryAccountId ? !c.isShared : c.accountId === accountId));
|
||||
const calendar = own.find((c) => c.isDefault) ?? own[0] ?? null;
|
||||
if (!calendar) return null;
|
||||
const rights = calendar.myRights;
|
||||
return !rights || rights.mayWriteAll || rights.mayWriteOwn ? calendar : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The block setting as it applies: locked, the organisation's value (the
|
||||
* lock's value, else the policy default, else on); hidden, the policy default
|
||||
* (else on), the user can't see the choice; otherwise the user's choice.
|
||||
* The settings card and the sync both read it here.
|
||||
*/
|
||||
export function resolveBlockSetting(
|
||||
restriction: { locked?: boolean; hidden?: boolean; value?: unknown } | undefined,
|
||||
policyDefault: unknown,
|
||||
userSetting: unknown,
|
||||
): { value: boolean; locked: boolean; hidden: boolean } {
|
||||
const orgValue = (v: unknown) => (typeof v === 'boolean' ? v : typeof policyDefault === 'boolean' ? policyDefault : true);
|
||||
if (restriction?.locked === true) return { value: orgValue('value' in restriction ? restriction.value : undefined), locked: true, hidden: false };
|
||||
if (restriction?.hidden === true) return { value: orgValue(undefined), locked: false, hidden: true };
|
||||
return { value: userSetting !== false, locked: false, hidden: false };
|
||||
}
|
||||
|
||||
export function effectiveBlockSetting(): boolean {
|
||||
const policy = usePolicyStore.getState();
|
||||
return resolveBlockSetting(
|
||||
policy.getRestriction('vacationBlockCalendar'),
|
||||
policy.getEffectiveDefault('vacationBlockCalendar'),
|
||||
useSettingsStore.getState().vacationBlockCalendar,
|
||||
).value;
|
||||
}
|
||||
|
||||
function writtenBlocks(): Record<string, WrittenBlock> {
|
||||
const value = useSettingsStore.getState().vacationCalendarBlocks;
|
||||
return value && typeof value === 'object' ? value : {};
|
||||
}
|
||||
|
||||
function rememberBlock(accountId: string, block: WrittenBlock | null): void {
|
||||
const next = { ...writtenBlocks() };
|
||||
if (block) next[accountId] = block;
|
||||
else delete next[accountId];
|
||||
useSettingsStore.getState().updateSetting('vacationCalendarBlocks', next);
|
||||
}
|
||||
|
||||
type SyncClient = Pick<IJMAPClient,
|
||||
'getAccountId' | 'supportsCalendars' | 'getAllCalendars' | 'queryCalendarEvents' | 'createCalendarEvent' | 'updateCalendarEvent' | 'deleteCalendarEvent'>;
|
||||
|
||||
/**
|
||||
* Bring the busy event of one account in line with the reply. Returns what
|
||||
* it did; never throws (a failure is logged and tried again next time).
|
||||
*/
|
||||
export async function syncVacationBlock(
|
||||
client: SyncClient,
|
||||
accountId: string | undefined,
|
||||
reply: ReplyState,
|
||||
trigger: 'save' | 'load',
|
||||
now = new Date(),
|
||||
previous: Pick<ReplyState, 'fromDate' | 'toDate'> | null = null,
|
||||
): Promise<BlockDecision['action'] | 'no-calendar' | 'failed'> {
|
||||
if (typeof client.supportsCalendars !== 'function' || !client.supportsCalendars()) return 'none';
|
||||
const primary = client.getAccountId();
|
||||
const account = accountId ?? primary;
|
||||
const target = account === primary ? undefined : account;
|
||||
const zone = getEffectiveTimeZone();
|
||||
try {
|
||||
const [found] = await client.queryCalendarEvents({ uid: oooUid(account) }, undefined, 1, target);
|
||||
const span = found ? eventSpan(found, zone) : null;
|
||||
const last = writtenBlocks()[account] ?? null;
|
||||
const marked = !!found?.keywords?.[OOO_KEYWORD];
|
||||
const decision = decideBlock({ block: effectiveBlockSetting(), reply, event: span, last, previous, marked, now, trigger });
|
||||
switch (decision.action) {
|
||||
case 'none':
|
||||
// A record of an event that is gone and not wanted any more is dropped.
|
||||
if (!found && last && !(reply.isEnabled && effectiveBlockSetting())) rememberBlock(account, null);
|
||||
return 'none';
|
||||
case 'create': {
|
||||
const calendar = blockCalendar(await client.getAllCalendars(), account, primary);
|
||||
if (!calendar) return 'no-calendar';
|
||||
await client.createCalendarEvent({
|
||||
'@type': 'Event',
|
||||
uid: oooUid(account),
|
||||
title: reply.subject.trim() || OUT_OF_OFFICE_TITLE,
|
||||
calendarIds: { [calendar.originalId ?? calendar.id]: true },
|
||||
...blockTimes(decision.start, decision.end, zone),
|
||||
freeBusyStatus: 'busy',
|
||||
privacy: 'public',
|
||||
status: 'confirmed',
|
||||
keywords: { [OOO_KEYWORD]: true },
|
||||
} as Partial<CalendarEvent>, false, target);
|
||||
rememberBlock(account, { start: decision.start.toISOString(), end: decision.end.toISOString() });
|
||||
return 'create';
|
||||
}
|
||||
case 'update':
|
||||
await client.updateCalendarEvent(found!.originalId ?? found!.id, {
|
||||
...blockTimes(decision.start, decision.end, zone),
|
||||
title: reply.subject.trim() || OUT_OF_OFFICE_TITLE,
|
||||
[`keywords/${OOO_KEYWORD}`]: true,
|
||||
} as Partial<CalendarEvent>, false, target);
|
||||
rememberBlock(account, { start: decision.start.toISOString(), end: decision.end.toISOString() });
|
||||
return 'update';
|
||||
case 'end-now': {
|
||||
const start = span!.start;
|
||||
// Whole minutes: Stalwart stores the duration in them.
|
||||
const end = new Date(Math.max(start.getTime() + 60000, Math.ceil(decision.end.getTime() / 60000) * 60000));
|
||||
await client.updateCalendarEvent(found!.originalId ?? found!.id, blockTimes(start, end, zone), false, target);
|
||||
rememberBlock(account, { start: start.toISOString(), end: blockEnd(start, end, zone).toISOString() });
|
||||
return 'end-now';
|
||||
}
|
||||
case 'destroy':
|
||||
await client.deleteCalendarEvent(found!.originalId ?? found!.id, false, target);
|
||||
rememberBlock(account, null);
|
||||
return 'destroy';
|
||||
}
|
||||
} catch (error) {
|
||||
debug.error('Out-of-office calendar block failed:', error);
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
/** The end as eventSpan will read it back after blockTimes wrote it. */
|
||||
function blockEnd(start: Date, end: Date, zone: string): Date {
|
||||
return eventSpan(blockTimes(start, end, zone), zone)?.end ?? end;
|
||||
}
|
||||
|
||||
/**
|
||||
* The event title when the reply has no subject. In English: the event is
|
||||
* seen by colleagues in any language, and a server-side copy can't follow
|
||||
* the reader's locale.
|
||||
*/
|
||||
export const OUT_OF_OFFICE_TITLE = 'Out of office';
|
||||
|
||||
function relevant(reply: ReplyState): string {
|
||||
return JSON.stringify([reply.isEnabled, reply.fromDate, reply.toDate, reply.subject]);
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
client: SyncClient;
|
||||
accountId: string | undefined;
|
||||
reply: ReplyState;
|
||||
/** The dates before the first change still waiting to be synced. */
|
||||
previous: Pick<ReplyState, 'fromDate' | 'toDate'> | null;
|
||||
trigger: 'save' | 'load';
|
||||
}
|
||||
|
||||
// Kept on globalThis: the store and the settings page may be in different
|
||||
// bundle copies, and two schedulers would sync the same event twice.
|
||||
interface SchedulerState {
|
||||
/** Per account: one job waiting, one timer, one sync running. */
|
||||
timers: Map<string, ReturnType<typeof setTimeout>>;
|
||||
pending: Map<string, Pending>;
|
||||
running: Map<string, Promise<unknown>>;
|
||||
loaded: Set<string>;
|
||||
}
|
||||
const SCHEDULER_KEY = '__bulwarkVacationCalendarSync';
|
||||
function scheduler(): SchedulerState {
|
||||
const g = globalThis as unknown as Record<string, SchedulerState | undefined>;
|
||||
return (g[SCHEDULER_KEY] ??= { timers: new Map(), pending: new Map(), running: new Map(), loaded: new Set() });
|
||||
}
|
||||
|
||||
function run(key: string): void {
|
||||
const state = scheduler();
|
||||
state.timers.delete(key);
|
||||
const job = state.pending.get(key);
|
||||
if (state.running.has(key) || !job) return;
|
||||
state.pending.delete(key);
|
||||
state.running.set(key, syncVacationBlock(job.client, job.accountId, job.reply, job.trigger, new Date(), job.previous)
|
||||
.finally(() => {
|
||||
state.running.delete(key);
|
||||
if (state.pending.has(key) && !state.timers.has(key)) run(key);
|
||||
}));
|
||||
}
|
||||
|
||||
function schedule(job: Pending): void {
|
||||
const state = scheduler();
|
||||
const key = job.accountId ?? job.client.getAccountId();
|
||||
const waiting = state.pending.get(key);
|
||||
// A save outranks a load that waits; the latest reply wins, with the
|
||||
// dates from before the first change it replaces.
|
||||
if (waiting?.trigger === 'save' && job.trigger === 'load') return;
|
||||
state.pending.set(key, waiting?.trigger === 'save' ? { ...job, previous: waiting.previous } : job);
|
||||
const timer = state.timers.get(key);
|
||||
if (timer) clearTimeout(timer);
|
||||
state.timers.set(key, setTimeout(() => run(key), SYNC_DEBOUNCE_MS));
|
||||
}
|
||||
|
||||
/** After a saved reply: sync when the switch, the dates or the subject changed. */
|
||||
export function vacationReplySaved(client: SyncClient, accountId: string | undefined, before: ReplyState, after: ReplyState): void {
|
||||
if (relevant(before) === relevant(after)) return;
|
||||
schedule({ client, accountId, reply: after, previous: { fromDate: before.fromDate, toDate: before.toDate }, trigger: 'save' });
|
||||
}
|
||||
|
||||
/** The user changed the block setting: sync with the reply as it is. */
|
||||
export function vacationBlockChanged(client: SyncClient, accountId: string | undefined, reply: ReplyState): void {
|
||||
schedule({ client, accountId, reply, previous: { fromDate: reply.fromDate, toDate: reply.toDate }, trigger: 'save' });
|
||||
}
|
||||
|
||||
/** After the reply was loaded: once per page load and account. */
|
||||
export function vacationReplyLoaded(client: SyncClient, accountId: string | undefined, reply: ReplyState): void {
|
||||
const key = accountId ?? client.getAccountId();
|
||||
const state = scheduler();
|
||||
if (state.loaded.has(key)) return;
|
||||
state.loaded.add(key);
|
||||
schedule({ client, accountId, reply, previous: null, trigger: 'load' });
|
||||
}
|
||||
|
||||
/** For tests: drop what the scheduler holds. */
|
||||
export function resetVacationCalendarSync(): void {
|
||||
const state = scheduler();
|
||||
for (const timer of state.timers.values()) clearTimeout(timer);
|
||||
state.timers.clear();
|
||||
state.pending.clear();
|
||||
state.running.clear();
|
||||
state.loaded.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether turning the reply on switches off a filter script of the user's
|
||||
* own: Stalwart runs one script at a time, and Bulwark keeps only its own
|
||||
* filters running next to the reply (stores/filter-store.ts, through an
|
||||
* include). A script it can't read, or any script on a server without
|
||||
* `include`, stops while the reply is on.
|
||||
*/
|
||||
export async function ownFilterScriptStops(
|
||||
client: Pick<IJMAPClient, 'getSieveScripts' | 'getSieveScriptContent' | 'getSieveCapabilities' | 'getSieveAccountId'>,
|
||||
accountId?: string,
|
||||
): Promise<boolean> {
|
||||
const account = accountId || client.getSieveAccountId();
|
||||
const scripts = await client.getSieveScripts(account);
|
||||
const active = scripts.find((s) => s.isActive && s.name !== VACATION_SCRIPT_NAME);
|
||||
if (!active) return false;
|
||||
if (!supportsInclude(client.getSieveCapabilities(account))) return true;
|
||||
const parsed = parseScript(await client.getSieveScriptContent(active.blobId, account));
|
||||
return parsed.isOpaque;
|
||||
}
|
||||
@@ -4845,6 +4845,51 @@
|
||||
"cancelled": "ألغى {name} \"{title}\"",
|
||||
"untitled": "حدث بدون عنوان"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "المؤسسة"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "حجز تقويمي",
|
||||
"description": "يضيف حدثًا مشغولًا لمدة غيابك، حتى لا يحجز أحد اجتماعات معك خلالها.",
|
||||
"no_access_managed": "لا يمكن حجز تقويم هذا الحساب: ليست لديك صلاحية الكتابة فيه.",
|
||||
"no_calendar": "لا يمكن حجز تقويمك: لم يجد Bulwark تقويمًا لإضافة الحدث إليه.",
|
||||
"filter_warning": "تشغيل هذا يوقف نص التصفية الخاص بك طوال مدة تفعيل الرد."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "الرد نيابةً عن {name}",
|
||||
"no_write": "يمكنك رؤية دعوات {name} لكن لا يمكنك الرد عليها. اطلب صلاحية التعديل.",
|
||||
"also_invited": "أنت مدعو أيضًا، ولا يستطيع هذا الخادم التمييز بين ردك ورد {name}. على {name} الرد على هذه الدعوة.",
|
||||
"answered_by": "ردّ عليها {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "مشاركة أوقات توفري مع {group}",
|
||||
"already": "أوقات توفرك مشتركة مع {group}",
|
||||
"done": "أصبحت أوقات توفرك مشتركة مع {group}.",
|
||||
"error": "تعذّرت مشاركة أوقات توفرك. حاول مرة أخرى.",
|
||||
"group_label": "المجموعة",
|
||||
"title": "متاح/مشغول",
|
||||
"description": "يرى زملاؤك في المجموعة متى تكون مشغولًا، لا تفاصيل أحداثك. تستخدمه ميزة اقتراح الأوقات."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "اقتراح أوقات",
|
||||
"searching": "جارٍ البحث عن أوقات متاحة",
|
||||
"slots_title": "متاح لكل من استطاع Bulwark التحقق منه",
|
||||
"slot": "{day}، من {start} إلى {end}",
|
||||
"none": "لا يوجد وقت متاح في أيام العمل العشرة القادمة.",
|
||||
"picked": "نُقل الحدث إلى {slot}",
|
||||
"rate_limited": "طلبات كثيرة جدًا في الدقيقة الأخيرة. حاول مرة أخرى بعد دقيقة.",
|
||||
"unavailable": "معلومات التوفر غير متاحة على هذا الخادم، لذا لا يستطيع Bulwark اقتراح أوقات.",
|
||||
"not_checked": "لم يُتحقق منهم:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "خارج مؤسستك",
|
||||
"reason_unknown": "أوقات الانشغال غير معروفة",
|
||||
"reason_not_found": "غير موجود على هذا الخادم",
|
||||
"reason_error": "تعذّر التحقق",
|
||||
"unknown_note": "يرى Bulwark أوقات الانشغال فقط في التقويمات المشتركة معك. لهؤلاء الأشخاص لا يمكنه التمييز بين متاح وغير مشترك.",
|
||||
"directory_off": "لا يستطيع Bulwark البحث عن الأشخاص على هذا الخادم. يمكن لمسؤولك تفعيل استعلامات الدليل في Stalwart.",
|
||||
"not_looked_up": "تعذّر العثور على هذا العنوان",
|
||||
"found": "الأوقات المتاحة التي وُجدت: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "شخص ما",
|
||||
"shared": "شارك {name} معك {kind} \"{object}\"",
|
||||
|
||||
@@ -4845,6 +4845,51 @@
|
||||
"cancelled": "{name} ha cancel·lat \"{title}\"",
|
||||
"untitled": "Esdeveniment sense títol"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Organització"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Bloca el meu calendari",
|
||||
"description": "Afegeix un esdeveniment ocupat mentre sou fora, perquè ningú us convoqui a reunions aleshores.",
|
||||
"no_access_managed": "No es pot blocar el calendari d’aquest compte: no hi teniu accés d’escriptura.",
|
||||
"no_calendar": "No es pot blocar el calendari: Bulwark no ha trobat cap calendari on afegir l’esdeveniment.",
|
||||
"filter_warning": "Si ho activeu, el vostre propi script de filtres s’aturarà mentre la resposta estigui activa."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "Respon en nom de {name}",
|
||||
"no_write": "Podeu veure les invitacions de {name}, però no podeu respondre en nom seu. Demaneu-li accés d’edició.",
|
||||
"also_invited": "També esteu convidat, i aquest servidor no distingeix la vostra resposta de la de {name}. {name} ha de respondre aquesta.",
|
||||
"answered_by": "Resposta de {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Comparteix la meva disponibilitat amb {group}",
|
||||
"already": "La vostra disponibilitat està compartida amb {group}",
|
||||
"done": "Ara la vostra disponibilitat està compartida amb {group}.",
|
||||
"error": "No s’ha pogut compartir la vostra disponibilitat. Torneu-ho a provar.",
|
||||
"group_label": "Grup",
|
||||
"title": "Disponibilitat",
|
||||
"description": "Els companys del grup veuen quan esteu ocupat, no quins esdeveniments teniu. «Suggereix horaris» ho fa servir."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Suggereix horaris",
|
||||
"searching": "S’estan cercant horaris lliures",
|
||||
"slots_title": "Lliure per a tothom que Bulwark ha pogut comprovar",
|
||||
"slot": "{day}, de {start} a {end}",
|
||||
"none": "No hi ha temps lliure en els propers 10 dies laborables.",
|
||||
"picked": "Esdeveniment mogut a {slot}",
|
||||
"rate_limited": "Massa consultes en l’últim minut. Torneu-ho a provar d’aquí a un minut.",
|
||||
"unavailable": "La disponibilitat no està disponible en aquest servidor, així que Bulwark no pot suggerir horaris.",
|
||||
"not_checked": "No comprovats:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "fora de la vostra organització",
|
||||
"reason_unknown": "hores ocupades desconegudes",
|
||||
"reason_not_found": "no trobat en aquest servidor",
|
||||
"reason_error": "no s’ha pogut comprovar",
|
||||
"unknown_note": "Bulwark només veu les hores ocupades dels calendaris compartits amb vós. Per a aquestes persones no pot distingir lliure de no compartit.",
|
||||
"directory_off": "Bulwark no pot cercar persones en aquest servidor. L’administrador pot activar les consultes de directori a Stalwart.",
|
||||
"not_looked_up": "No s’ha pogut cercar aquesta adreça",
|
||||
"found": "Horaris lliures trobats: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Algú",
|
||||
"shared": "{name} ha compartit amb tu {kind} \"{object}\"",
|
||||
|
||||
@@ -5283,6 +5283,51 @@
|
||||
"cancelled": "{name} zrušil(a) „{title}“",
|
||||
"untitled": "Událost bez názvu"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Organizace"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Zablokovat můj kalendář",
|
||||
"description": "Přidá obsazenou událost na dobu vaší nepřítomnosti, aby vám tehdy nikdo neplánoval schůzky.",
|
||||
"no_access_managed": "Kalendář tohoto účtu nelze zablokovat: nemáte do něj přístup pro zápis.",
|
||||
"no_calendar": "Kalendář nelze zablokovat: Bulwark nenašel kalendář, kam událost přidat.",
|
||||
"filter_warning": "Zapnutím se vypne váš vlastní skript filtrů, dokud je odpověď aktivní."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "Odpovědět za: {name}",
|
||||
"no_write": "Vidíte pozvánky uživatele {name}, ale nemůžete za něj odpovídat. Požádejte o přístup k úpravám.",
|
||||
"also_invited": "Jste pozváni také a tento server nerozliší vaši odpověď od odpovědi uživatele {name}. Na tuto musí odpovědět {name}.",
|
||||
"answered_by": "Odpověděl(a): {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Sdílet můj volný čas se skupinou {group}",
|
||||
"already": "Váš volný čas je sdílen se skupinou {group}",
|
||||
"done": "Váš volný čas je nyní sdílen se skupinou {group}.",
|
||||
"error": "Volný čas se nepodařilo sdílet. Zkuste to znovu.",
|
||||
"group_label": "Skupina",
|
||||
"title": "Volno/obsazeno",
|
||||
"description": "Kolegové ve skupině vidí, kdy jste obsazeni, ne jaké máte události. Používá to funkce Navrhnout časy."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Navrhnout časy",
|
||||
"searching": "Hledají se volné časy",
|
||||
"slots_title": "Volno pro všechny, které Bulwark mohl ověřit",
|
||||
"slot": "{day}, {start} až {end}",
|
||||
"none": "V příštích 10 pracovních dnech není volný čas.",
|
||||
"picked": "Událost přesunuta na {slot}",
|
||||
"rate_limited": "V poslední minutě bylo příliš mnoho dotazů. Zkuste to za minutu znovu.",
|
||||
"unavailable": "Informace o volnu nejsou na tomto serveru k dispozici, takže Bulwark nemůže navrhnout časy.",
|
||||
"not_checked": "Neověřeno:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "mimo vaši organizaci",
|
||||
"reason_unknown": "obsazené časy neznámé",
|
||||
"reason_not_found": "na tomto serveru nenalezen",
|
||||
"reason_error": "nelze ověřit",
|
||||
"unknown_note": "Bulwark vidí obsazené časy jen v kalendářích, které s vámi někdo sdílí. U těchto lidí nerozliší volno od nesdíleného kalendáře.",
|
||||
"directory_off": "Bulwark na tomto serveru nemůže vyhledávat lidi. Správce může ve Stalwartu zapnout dotazy do adresáře.",
|
||||
"not_looked_up": "Adresu nelze vyhledat",
|
||||
"found": "Nalezené volné časy: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Někdo",
|
||||
"shared": "{name} s vámi sdílí {kind} „{object}“",
|
||||
|
||||
@@ -4845,6 +4845,51 @@
|
||||
"cancelled": "{name} har aflyst \"{title}\"",
|
||||
"untitled": "Unavngivet begivenhed"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Organisation"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Bloker min kalender",
|
||||
"description": "Tilføjer en optaget begivenhed, mens du er væk, så ingen booker møder med dig imens.",
|
||||
"no_access_managed": "Kalenderen for denne konto kan ikke blokeres: du har ikke skriveadgang til den.",
|
||||
"no_calendar": "Din kalender kan ikke blokeres: Bulwark fandt ingen kalender at tilføje begivenheden til.",
|
||||
"filter_warning": "Slår du dette til, slås dit eget filterscript fra, mens svaret er aktivt."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "Svar for {name}",
|
||||
"no_write": "Du kan se invitationerne til {name}, men kan ikke svare for dem. Bed om redigeringsadgang.",
|
||||
"also_invited": "Du er også inviteret, og denne server kan ikke skelne dit svar fra svaret fra {name}. {name} skal selv svare på denne.",
|
||||
"answered_by": "Besvaret af {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Del min ledig/optaget med {group}",
|
||||
"already": "Din ledig/optaget er delt med {group}",
|
||||
"done": "Din ledig/optaget er nu delt med {group}.",
|
||||
"error": "Din ledig/optaget kunne ikke deles. Prøv igen.",
|
||||
"group_label": "Gruppe",
|
||||
"title": "Ledig/optaget",
|
||||
"description": "Kolleger i gruppen ser, hvornår du er optaget, ikke hvad dine begivenheder er. Foreslå tidspunkter bruger det."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Foreslå tidspunkter",
|
||||
"searching": "Leder efter ledige tidspunkter",
|
||||
"slots_title": "Ledigt for alle, Bulwark kunne tjekke",
|
||||
"slot": "{day}, {start} til {end}",
|
||||
"none": "Ingen ledig tid i de næste 10 arbejdsdage.",
|
||||
"picked": "Begivenhed flyttet til {slot}",
|
||||
"rate_limited": "For mange opslag i det sidste minut. Prøv igen om et minut.",
|
||||
"unavailable": "Ledig/optaget er ikke tilgængelig på denne server, så Bulwark kan ikke foreslå tidspunkter.",
|
||||
"not_checked": "Ikke tjekket:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "uden for din organisation",
|
||||
"reason_unknown": "optagede tider ukendte",
|
||||
"reason_not_found": "ikke fundet på denne server",
|
||||
"reason_error": "kunne ikke tjekkes",
|
||||
"unknown_note": "Bulwark ser kun optagede tider i kalendere, der er delt med dig. For disse personer kan den ikke skelne ledig fra ikke delt.",
|
||||
"directory_off": "Bulwark kan ikke slå personer op på denne server. Din administrator kan slå katalogforespørgsler til i Stalwart.",
|
||||
"not_looked_up": "Adressen kunne ikke slås op",
|
||||
"found": "Ledige tidspunkter fundet: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Nogen",
|
||||
"shared": "{name} har delt {kind} \"{object}\" med dig",
|
||||
|
||||
@@ -5283,6 +5283,51 @@
|
||||
"cancelled": "{name} hat „{title}“ abgesagt",
|
||||
"untitled": "Unbenannter Termin"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Organisation"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Meinen Kalender blockieren",
|
||||
"description": "Trägt für Ihre Abwesenheit einen belegten Termin ein, damit Ihnen niemand Besprechungen in dieser Zeit einträgt.",
|
||||
"no_access_managed": "Der Kalender dieses Kontos kann nicht blockiert werden: Sie haben keinen Schreibzugriff darauf.",
|
||||
"no_calendar": "Ihr Kalender kann nicht blockiert werden: Bulwark hat keinen Kalender für den Termin gefunden.",
|
||||
"filter_warning": "Wenn Sie dies einschalten, ist Ihr eigenes Filterskript ausgeschaltet, solange die Antwort aktiv ist."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "Für {name} antworten",
|
||||
"no_write": "Sie sehen die Einladungen von {name}, können aber nicht dafür antworten. Bitten Sie um Bearbeitungszugriff.",
|
||||
"also_invited": "Sie sind ebenfalls eingeladen, und dieser Server kann Ihre Antwort nicht von der von {name} unterscheiden. {name} muss hier selbst antworten.",
|
||||
"answered_by": "Beantwortet von {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Meine Frei/Gebucht-Zeiten für {group} freigeben",
|
||||
"already": "Ihre Frei/Gebucht-Zeiten sind für {group} freigegeben",
|
||||
"done": "Ihre Frei/Gebucht-Zeiten sind jetzt für {group} freigegeben.",
|
||||
"error": "Ihre Frei/Gebucht-Zeiten konnten nicht freigegeben werden. Versuchen Sie es erneut.",
|
||||
"group_label": "Gruppe",
|
||||
"title": "Frei/Gebucht",
|
||||
"description": "Kolleginnen und Kollegen in der Gruppe sehen, wann Sie belegt sind, aber nicht, welche Termine Sie haben. „Zeiten vorschlagen“ nutzt das."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Zeiten vorschlagen",
|
||||
"searching": "Freie Zeiten werden gesucht",
|
||||
"slots_title": "Frei für alle, die Bulwark prüfen konnte",
|
||||
"slot": "{day}, {start} bis {end}",
|
||||
"none": "In den nächsten 10 Arbeitstagen ist keine Zeit frei.",
|
||||
"picked": "Termin verschoben auf {slot}",
|
||||
"rate_limited": "Zu viele Abfragen in der letzten Minute. Versuchen Sie es in einer Minute erneut.",
|
||||
"unavailable": "Frei/Gebucht ist auf diesem Server nicht verfügbar, daher kann Bulwark keine Zeiten vorschlagen.",
|
||||
"not_checked": "Nicht geprüft:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "außerhalb Ihrer Organisation",
|
||||
"reason_unknown": "belegte Zeiten unbekannt",
|
||||
"reason_not_found": "auf diesem Server nicht gefunden",
|
||||
"reason_error": "konnte nicht geprüft werden",
|
||||
"unknown_note": "Bulwark sieht belegte Zeiten nur in Kalendern, die für Sie freigegeben sind. Bei diesen Personen kann es „frei“ nicht von „nicht freigegeben“ unterscheiden.",
|
||||
"directory_off": "Bulwark kann auf diesem Server keine Personen nachschlagen. Ihre Administration kann Verzeichnisabfragen in Stalwart einschalten.",
|
||||
"not_looked_up": "Adresse nicht gefunden",
|
||||
"found": "Freie Zeiten gefunden: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Jemand",
|
||||
"shared": "{name} hat {kind} „{object}“ mit Ihnen geteilt",
|
||||
|
||||
@@ -4845,6 +4845,51 @@
|
||||
"cancelled": "{name} cancelled \"{title}\"",
|
||||
"untitled": "Untitled event"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Organisation"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Block my calendar",
|
||||
"description": "Adds a busy event for the time you’re away, so nobody books meetings with you then.",
|
||||
"no_access_managed": "The calendar of this account can’t be blocked: you don’t have write access to it.",
|
||||
"no_calendar": "Your calendar can’t be blocked: Bulwark found no calendar to add the event to.",
|
||||
"filter_warning": "Turning this on switches off your own filter script while the reply is active."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "Answer for {name}",
|
||||
"no_write": "You can see {name}’s invitations but can’t answer for them. Ask them for edit access.",
|
||||
"also_invited": "You’re invited too, and this server can’t tell your answer from {name}’s. {name} has to answer this one.",
|
||||
"answered_by": "Answered by {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Share my free/busy with {group}",
|
||||
"already": "Your free/busy is shared with {group}",
|
||||
"done": "Your free/busy is now shared with {group}.",
|
||||
"error": "Couldn’t share your free/busy. Try again.",
|
||||
"group_label": "Group",
|
||||
"title": "Free/busy",
|
||||
"description": "Colleagues in the group see when you’re busy, not what your events are. Suggest times uses it."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Suggest times",
|
||||
"searching": "Looking for free times",
|
||||
"slots_title": "Free for everyone Bulwark could check",
|
||||
"slot": "{day}, {start} to {end}",
|
||||
"none": "No free time in the next 10 working days.",
|
||||
"picked": "Event moved to {slot}",
|
||||
"rate_limited": "Too many lookups in the last minute. Try again in a minute.",
|
||||
"unavailable": "Free/busy isn’t available on this server, so Bulwark can’t suggest times.",
|
||||
"not_checked": "Not checked:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "outside your organisation",
|
||||
"reason_unknown": "busy times unknown",
|
||||
"reason_not_found": "not found on this server",
|
||||
"reason_error": "couldn’t be checked",
|
||||
"unknown_note": "Bulwark only sees busy times in calendars shared with you. For these people it can’t tell free from not shared.",
|
||||
"directory_off": "Bulwark can’t look up people on this server. Your administrator can turn on directory queries in Stalwart.",
|
||||
"not_looked_up": "Couldn’t look up this address",
|
||||
"found": "Free times found: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Someone",
|
||||
"shared": "{name} shared the {kind} \"{object}\" with you",
|
||||
|
||||
@@ -5283,6 +5283,51 @@
|
||||
"cancelled": "{name} ha cancelado \"{title}\"",
|
||||
"untitled": "Evento sin título"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Organización"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Bloquear mi calendario",
|
||||
"description": "Añade un evento ocupado mientras estás fuera, para que nadie te convoque a reuniones entonces.",
|
||||
"no_access_managed": "No se puede bloquear el calendario de esta cuenta: no tienes acceso de escritura.",
|
||||
"no_calendar": "No se puede bloquear tu calendario: Bulwark no encontró ningún calendario donde añadir el evento.",
|
||||
"filter_warning": "Si lo activas, tu propio script de filtros se desactiva mientras la respuesta esté activa."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "Responder por {name}",
|
||||
"no_write": "Puedes ver las invitaciones de {name}, pero no responder por esa persona. Pídele acceso de edición.",
|
||||
"also_invited": "Tú también estás invitado y este servidor no distingue tu respuesta de la de {name}. {name} tiene que responder esta.",
|
||||
"answered_by": "Respondido por {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Compartir mi disponibilidad con {group}",
|
||||
"already": "Tu disponibilidad está compartida con {group}",
|
||||
"done": "Ahora tu disponibilidad está compartida con {group}.",
|
||||
"error": "No se pudo compartir tu disponibilidad. Inténtalo de nuevo.",
|
||||
"group_label": "Grupo",
|
||||
"title": "Disponibilidad",
|
||||
"description": "Tus compañeros del grupo ven cuándo estás ocupado, no qué eventos tienes. «Sugerir horarios» lo usa."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Sugerir horarios",
|
||||
"searching": "Buscando horarios libres",
|
||||
"slots_title": "Libre para todos los que Bulwark pudo comprobar",
|
||||
"slot": "{day}, de {start} a {end}",
|
||||
"none": "No hay tiempo libre en los próximos 10 días laborables.",
|
||||
"picked": "Evento movido a {slot}",
|
||||
"rate_limited": "Demasiadas consultas en el último minuto. Inténtalo de nuevo en un minuto.",
|
||||
"unavailable": "La disponibilidad no está disponible en este servidor, así que Bulwark no puede sugerir horarios.",
|
||||
"not_checked": "Sin comprobar:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "fuera de tu organización",
|
||||
"reason_unknown": "horas ocupadas desconocidas",
|
||||
"reason_not_found": "no encontrado en este servidor",
|
||||
"reason_error": "no se pudo comprobar",
|
||||
"unknown_note": "Bulwark solo ve las horas ocupadas de los calendarios compartidos contigo. Para estas personas no puede distinguir libre de no compartido.",
|
||||
"directory_off": "Bulwark no puede buscar personas en este servidor. Tu administrador puede activar las consultas de directorio en Stalwart.",
|
||||
"not_looked_up": "No se pudo buscar esta dirección",
|
||||
"found": "Horarios libres encontrados: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Alguien",
|
||||
"shared": "{name} ha compartido contigo {kind} \"{object}\"",
|
||||
|
||||
@@ -4845,6 +4845,51 @@
|
||||
"cancelled": "{name} tühistas sündmuse \"{title}\"",
|
||||
"untitled": "Nimetu sündmus"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Organisatsioon"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Blokeeri minu kalender",
|
||||
"description": "Lisab äraoleku ajaks hõivatud sündmuse, et keegi sel ajal sulle koosolekuid ei paneks.",
|
||||
"no_access_managed": "Selle konto kalendrit ei saa blokeerida: sul pole sinna kirjutusõigust.",
|
||||
"no_calendar": "Kalendrit ei saa blokeerida: Bulwark ei leidnud kalendrit, kuhu sündmus lisada.",
|
||||
"filter_warning": "Selle sisselülitamine lülitab sinu oma filtriskripti välja, kuni vastus on aktiivne."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "Vasta isiku {name} eest",
|
||||
"no_write": "Näed isiku {name} kutseid, aga ei saa tema eest vastata. Küsi temalt muutmisõigust.",
|
||||
"also_invited": "Oled samuti kutsutud ja see server ei erista sinu vastust isiku {name} vastusest. Sellele peab vastama {name}.",
|
||||
"answered_by": "Vastas {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Jaga minu vaba/hõivatud aega grupiga {group}",
|
||||
"already": "Sinu vaba/hõivatud aeg on jagatud grupiga {group}",
|
||||
"done": "Sinu vaba/hõivatud aeg on nüüd jagatud grupiga {group}.",
|
||||
"error": "Vaba/hõivatud aega ei saanud jagada. Proovi uuesti.",
|
||||
"group_label": "Grupp",
|
||||
"title": "Vaba/hõivatud",
|
||||
"description": "Grupi kolleegid näevad, millal oled hõivatud, aga mitte sinu sündmusi. Seda kasutab „Soovita aegu“."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Soovita aegu",
|
||||
"searching": "Vabu aegu otsitakse",
|
||||
"slots_title": "Vaba kõigile, keda Bulwark sai kontrollida",
|
||||
"slot": "{day}, {start} kuni {end}",
|
||||
"none": "Järgmise 10 tööpäeva jooksul pole vaba aega.",
|
||||
"picked": "Sündmus viidi aega {slot}",
|
||||
"rate_limited": "Viimase minuti jooksul oli liiga palju päringuid. Proovi minuti pärast uuesti.",
|
||||
"unavailable": "Vaba/hõivatud teave pole selles serveris saadaval, seega ei saa Bulwark aegu soovitada.",
|
||||
"not_checked": "Kontrollimata:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "väljaspool sinu organisatsiooni",
|
||||
"reason_unknown": "hõivatud ajad teadmata",
|
||||
"reason_not_found": "selles serveris ei leitud",
|
||||
"reason_error": "ei saanud kontrollida",
|
||||
"unknown_note": "Bulwark näeb hõivatud aegu ainult sinuga jagatud kalendrites. Nende inimeste puhul ei saa ta eristada vaba ajast jagamata kalendrit.",
|
||||
"directory_off": "Bulwark ei saa selles serveris inimesi otsida. Administraator saab Stalwartis kataloogipäringud sisse lülitada.",
|
||||
"not_looked_up": "Seda aadressi ei leitud",
|
||||
"found": "Leitud vabu aegu: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Keegi",
|
||||
"shared": "{name} jagas sinuga {kind} \"{object}\"",
|
||||
|
||||
@@ -4845,6 +4845,51 @@
|
||||
"cancelled": "{name} «{title}» را لغو کرد",
|
||||
"untitled": "رویداد بدون عنوان"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "سازمان"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "مسدود کردن تقویم من",
|
||||
"description": "برای مدت غیبت شما یک رویداد مشغول اضافه میکند تا کسی در آن زمان جلسه نگذارد.",
|
||||
"no_access_managed": "تقویم این حساب را نمیتوان مسدود کرد: دسترسی نوشتن به آن ندارید.",
|
||||
"no_calendar": "تقویم شما را نمیتوان مسدود کرد: Bulwark تقویمی برای افزودن رویداد پیدا نکرد.",
|
||||
"filter_warning": "روشن کردن این، اسکریپت فیلتر خودتان را تا وقتی پاسخ فعال است خاموش میکند."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "پاسخ بهجای {name}",
|
||||
"no_write": "دعوتهای {name} را میبینید اما نمیتوانید بهجای او پاسخ دهید. دسترسی ویرایش بخواهید.",
|
||||
"also_invited": "شما هم دعوت شدهاید و این سرور پاسخ شما را از پاسخ {name} تشخیص نمیدهد. {name} باید خودش پاسخ دهد.",
|
||||
"answered_by": "پاسخدهنده: {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "همرسانی آزاد/مشغول من با {group}",
|
||||
"already": "آزاد/مشغول شما با {group} همرسانی شده است",
|
||||
"done": "آزاد/مشغول شما اکنون با {group} همرسانی شد.",
|
||||
"error": "همرسانی آزاد/مشغول شما انجام نشد. دوباره تلاش کنید.",
|
||||
"group_label": "گروه",
|
||||
"title": "آزاد/مشغول",
|
||||
"description": "همکاران گروه میبینند چه زمانی مشغول هستید، نه اینکه رویدادهایتان چیست. «پیشنهاد زمان» از آن استفاده میکند."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "پیشنهاد زمان",
|
||||
"searching": "در حال جستوجوی زمانهای آزاد",
|
||||
"slots_title": "آزاد برای همهٔ کسانی که Bulwark توانست بررسی کند",
|
||||
"slot": "{day}، از {start} تا {end}",
|
||||
"none": "در ۱۰ روز کاری آینده زمان آزادی نیست.",
|
||||
"picked": "رویداد به {slot} منتقل شد",
|
||||
"rate_limited": "در دقیقهٔ گذشته درخواستهای زیادی ارسال شد. یک دقیقهٔ دیگر دوباره تلاش کنید.",
|
||||
"unavailable": "اطلاعات آزاد/مشغول در این سرور در دسترس نیست، بنابراین Bulwark نمیتواند زمان پیشنهاد دهد.",
|
||||
"not_checked": "بررسینشده:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "خارج از سازمان شما",
|
||||
"reason_unknown": "زمانهای مشغول نامعلوم",
|
||||
"reason_not_found": "در این سرور پیدا نشد",
|
||||
"reason_error": "بررسی نشد",
|
||||
"unknown_note": "Bulwark فقط زمانهای مشغول را در تقویمهایی میبیند که با شما همرسانی شدهاند. برای این افراد نمیتواند آزاد را از همرسانینشده تشخیص دهد.",
|
||||
"directory_off": "Bulwark نمیتواند در این سرور افراد را جستوجو کند. مدیر شما میتواند پرسوجوی فهرست را در Stalwart روشن کند.",
|
||||
"not_looked_up": "این نشانی پیدا نشد",
|
||||
"found": "زمانهای آزاد پیدا شده: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "کسی",
|
||||
"shared": "{name} {kind} «{object}» را با شما به اشتراک گذاشت",
|
||||
|
||||
@@ -5283,6 +5283,51 @@
|
||||
"cancelled": "{name} a annulé « {title} »",
|
||||
"untitled": "Événement sans titre"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Organisation"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Bloquer mon calendrier",
|
||||
"description": "Ajoute un événement occupé pendant votre absence, pour que personne ne vous invite à une réunion à ce moment-là.",
|
||||
"no_access_managed": "Le calendrier de ce compte ne peut pas être bloqué : vous n’y avez pas accès en écriture.",
|
||||
"no_calendar": "Votre calendrier ne peut pas être bloqué : Bulwark n’a trouvé aucun calendrier où ajouter l’événement.",
|
||||
"filter_warning": "L’activer désactive votre propre script de filtres tant que la réponse est active."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "Répondre pour {name}",
|
||||
"no_write": "Vous voyez les invitations de {name}, mais vous ne pouvez pas y répondre à sa place. Demandez-lui un accès en modification.",
|
||||
"also_invited": "Vous êtes aussi invité, et ce serveur ne distingue pas votre réponse de celle de {name}. {name} doit répondre lui-même à celle-ci.",
|
||||
"answered_by": "Réponse de {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Partager mes disponibilités avec {group}",
|
||||
"already": "Vos disponibilités sont partagées avec {group}",
|
||||
"done": "Vos disponibilités sont maintenant partagées avec {group}.",
|
||||
"error": "Impossible de partager vos disponibilités. Réessayez.",
|
||||
"group_label": "Groupe",
|
||||
"title": "Disponibilités",
|
||||
"description": "Les collègues du groupe voient quand vous êtes occupé, pas le contenu de vos événements. « Suggérer des créneaux » s’en sert."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Suggérer des créneaux",
|
||||
"searching": "Recherche de créneaux libres",
|
||||
"slots_title": "Libre pour toutes les personnes que Bulwark a pu vérifier",
|
||||
"slot": "{day}, de {start} à {end}",
|
||||
"none": "Aucun créneau libre dans les 10 prochains jours ouvrés.",
|
||||
"picked": "Événement déplacé : {slot}",
|
||||
"rate_limited": "Trop de recherches dans la dernière minute. Réessayez dans une minute.",
|
||||
"unavailable": "La disponibilité n’est pas accessible sur ce serveur, Bulwark ne peut donc pas suggérer de créneaux.",
|
||||
"not_checked": "Non vérifiés :",
|
||||
"not_checked_item": "{who} : {reason}",
|
||||
"reason_external": "hors de votre organisation",
|
||||
"reason_unknown": "disponibilités inconnues",
|
||||
"reason_not_found": "introuvable sur ce serveur",
|
||||
"reason_error": "vérification impossible",
|
||||
"unknown_note": "Bulwark ne voit les disponibilités que dans les calendriers partagés avec vous. Pour ces personnes, il ne peut pas distinguer « libre » de « non partagé ».",
|
||||
"directory_off": "Bulwark ne peut pas rechercher de personnes sur ce serveur. Votre administrateur peut activer les requêtes d’annuaire dans Stalwart.",
|
||||
"not_looked_up": "Adresse introuvable",
|
||||
"found": "Créneaux libres trouvés : {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Quelqu'un",
|
||||
"shared": "{name} a partagé {kind} « {object} » avec vous",
|
||||
|
||||
@@ -5255,6 +5255,51 @@
|
||||
"cancelled": "{name} ביטל/ה את \"{title}\"",
|
||||
"untitled": "אירוע ללא כותרת"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "ארגון"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "חסום את היומן שלי",
|
||||
"description": "מוסיף אירוע תפוס לזמן ההיעדרות, כדי שאף אחד לא יקבע איתך פגישות אז.",
|
||||
"no_access_managed": "אי אפשר לחסום את היומן של החשבון הזה: אין לך הרשאת כתיבה אליו.",
|
||||
"no_calendar": "אי אפשר לחסום את היומן: Bulwark לא מצא יומן להוסיף אליו את האירוע.",
|
||||
"filter_warning": "הפעלה תכבה את סקריפט הסינון שלך כל עוד התשובה פעילה."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "השב בשם {name}",
|
||||
"no_write": "אפשר לראות את ההזמנות של {name}, אבל אי אפשר להשיב בשמו. בקשו הרשאת עריכה.",
|
||||
"also_invited": "גם אתם מוזמנים, והשרת הזה לא מבדיל בין התשובה שלכם לזו של {name}. {name} צריך להשיב בעצמו.",
|
||||
"answered_by": "נענה על ידי {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "שתף את הזמינות שלי עם {group}",
|
||||
"already": "הזמינות שלך משותפת עם {group}",
|
||||
"done": "הזמינות שלך משותפת עכשיו עם {group}.",
|
||||
"error": "לא ניתן היה לשתף את הזמינות שלך. נסו שוב.",
|
||||
"group_label": "קבוצה",
|
||||
"title": "פנוי/תפוס",
|
||||
"description": "עמיתים בקבוצה רואים מתי אתה תפוס, לא מה האירועים שלך. \"הצע זמנים\" משתמש בזה."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "הצע זמנים",
|
||||
"searching": "מחפש זמנים פנויים",
|
||||
"slots_title": "פנוי לכל מי ש-Bulwark הצליח לבדוק",
|
||||
"slot": "{day}, {start} עד {end}",
|
||||
"none": "אין זמן פנוי ב-10 ימי העבודה הבאים.",
|
||||
"picked": "האירוע הועבר אל {slot}",
|
||||
"rate_limited": "יותר מדי בדיקות בדקה האחרונה. נסו שוב בעוד דקה.",
|
||||
"unavailable": "מידע על פנוי/תפוס אינו זמין בשרת הזה, ולכן Bulwark לא יכול להציע זמנים.",
|
||||
"not_checked": "לא נבדקו:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "מחוץ לארגון שלך",
|
||||
"reason_unknown": "זמנים תפוסים לא ידועים",
|
||||
"reason_not_found": "לא נמצא בשרת הזה",
|
||||
"reason_error": "לא ניתן היה לבדוק",
|
||||
"unknown_note": "Bulwark רואה זמנים תפוסים רק ביומנים ששותפו איתך. אצל האנשים האלה הוא לא יכול להבדיל בין פנוי ללא משותף.",
|
||||
"directory_off": "Bulwark לא יכול לחפש אנשים בשרת הזה. מנהל המערכת יכול להפעיל שאילתות ספרייה ב-Stalwart.",
|
||||
"not_looked_up": "לא ניתן היה לחפש את הכתובת",
|
||||
"found": "זמנים פנויים שנמצאו: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "מישהו",
|
||||
"shared": "{name} שיתף/ה איתך את {kind} \"{object}\"",
|
||||
|
||||
@@ -4845,6 +4845,51 @@
|
||||
"cancelled": "{name} lemondta a(z) „{title}” eseményt",
|
||||
"untitled": "Névtelen esemény"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Szervezet"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Naptáram foglalása",
|
||||
"description": "Foglalt eseményt tesz a távollét idejére, hogy addig senki ne szervezzen Önnel megbeszélést.",
|
||||
"no_access_managed": "Ennek a fióknak a naptára nem foglalható: nincs hozzá írási joga.",
|
||||
"no_calendar": "A naptár nem foglalható: a Bulwark nem talált naptárt az eseményhez.",
|
||||
"filter_warning": "Ha bekapcsolja, a saját szűrőszkriptje kikapcsol, amíg a válasz aktív."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "Válasz {name} nevében",
|
||||
"no_write": "Látja {name} meghívóit, de nem válaszolhat a nevében. Kérjen szerkesztési jogot.",
|
||||
"also_invited": "Ön is meghívott, és ez a szerver nem tudja megkülönböztetni az Ön válaszát {name} válaszától. Erre {name} válaszoljon.",
|
||||
"answered_by": "Válaszolt: {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Szabad/foglalt időim megosztása ezzel: {group}",
|
||||
"already": "Szabad/foglalt idői meg vannak osztva ezzel: {group}",
|
||||
"done": "Szabad/foglalt idői mostantól meg vannak osztva ezzel: {group}.",
|
||||
"error": "Nem sikerült megosztani a szabad/foglalt időket. Próbálja újra.",
|
||||
"group_label": "Csoport",
|
||||
"title": "Szabad/foglalt",
|
||||
"description": "A csoport tagjai látják, mikor foglalt, de az eseményeit nem. Az Időpontok javaslata ezt használja."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Időpontok javaslata",
|
||||
"searching": "Szabad időpontok keresése",
|
||||
"slots_title": "Szabad mindenkinek, akit a Bulwark ellenőrizni tudott",
|
||||
"slot": "{day}, {start}–{end}",
|
||||
"none": "A következő 10 munkanapon nincs szabad idő.",
|
||||
"picked": "Az esemény áthelyezve: {slot}",
|
||||
"rate_limited": "Túl sok lekérdezés az elmúlt percben. Próbálja újra egy perc múlva.",
|
||||
"unavailable": "A szabad/foglalt adatok ezen a szerveren nem érhetők el, ezért a Bulwark nem tud időpontot javasolni.",
|
||||
"not_checked": "Nem ellenőrzött:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "a szervezetén kívül",
|
||||
"reason_unknown": "foglalt idők ismeretlenek",
|
||||
"reason_not_found": "nem található ezen a szerveren",
|
||||
"reason_error": "nem sikerült ellenőrizni",
|
||||
"unknown_note": "A Bulwark csak az Önnel megosztott naptárakban látja a foglalt időket. Ezeknél a személyeknél nem tudja megkülönböztetni a szabadot a nem megosztottól.",
|
||||
"directory_off": "A Bulwark ezen a szerveren nem tud személyeket keresni. A rendszergazda a Stalwartban bekapcsolhatja a címtár-lekérdezéseket.",
|
||||
"not_looked_up": "A cím nem kereshető",
|
||||
"found": "Talált szabad időpontok: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Valaki",
|
||||
"shared": "{name} megosztotta veled a(z) {kind} „{object}” elemet",
|
||||
|
||||
@@ -5283,6 +5283,51 @@
|
||||
"cancelled": "{name} ha annullato \"{title}\"",
|
||||
"untitled": "Evento senza titolo"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Organizzazione"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Blocca il mio calendario",
|
||||
"description": "Aggiunge un evento occupato per il periodo di assenza, così nessuno ti fissa riunioni in quei giorni.",
|
||||
"no_access_managed": "Non è possibile bloccare il calendario di questo account: non hai accesso in scrittura.",
|
||||
"no_calendar": "Impossibile bloccare il calendario: Bulwark non ha trovato un calendario a cui aggiungere l’evento.",
|
||||
"filter_warning": "Attivandolo, il tuo script di filtri viene disattivato finché la risposta è attiva."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "Rispondi per {name}",
|
||||
"no_write": "Puoi vedere gli inviti di {name} ma non rispondere al suo posto. Chiedi l’accesso in modifica.",
|
||||
"also_invited": "Sei invitato anche tu e questo server non distingue la tua risposta da quella di {name}. Deve rispondere {name}.",
|
||||
"answered_by": "Risposto da {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Condividi la mia disponibilità con {group}",
|
||||
"already": "La tua disponibilità è condivisa con {group}",
|
||||
"done": "Ora la tua disponibilità è condivisa con {group}.",
|
||||
"error": "Impossibile condividere la tua disponibilità. Riprova.",
|
||||
"group_label": "Gruppo",
|
||||
"title": "Disponibilità",
|
||||
"description": "I colleghi del gruppo vedono quando sei occupato, non quali eventi hai. «Suggerisci orari» lo usa."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Suggerisci orari",
|
||||
"searching": "Ricerca di orari liberi",
|
||||
"slots_title": "Libero per tutti quelli che Bulwark ha potuto controllare",
|
||||
"slot": "{day}, dalle {start} alle {end}",
|
||||
"none": "Nessun orario libero nei prossimi 10 giorni lavorativi.",
|
||||
"picked": "Evento spostato a {slot}",
|
||||
"rate_limited": "Troppe richieste nell’ultimo minuto. Riprova tra un minuto.",
|
||||
"unavailable": "La disponibilità non è accessibile su questo server, quindi Bulwark non può suggerire orari.",
|
||||
"not_checked": "Non controllati:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "esterno alla tua organizzazione",
|
||||
"reason_unknown": "orari occupati sconosciuti",
|
||||
"reason_not_found": "non trovato su questo server",
|
||||
"reason_error": "impossibile controllare",
|
||||
"unknown_note": "Bulwark vede gli orari occupati solo nei calendari condivisi con te. Per queste persone non può distinguere libero da non condiviso.",
|
||||
"directory_off": "Bulwark non può cercare persone su questo server. L’amministratore può attivare le query della rubrica in Stalwart.",
|
||||
"not_looked_up": "Impossibile cercare questo indirizzo",
|
||||
"found": "Orari liberi trovati: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Qualcuno",
|
||||
"shared": "{name} ha condiviso con te {kind} \"{object}\"",
|
||||
|
||||
@@ -5283,6 +5283,51 @@
|
||||
"cancelled": "{name} さんが「{title}」をキャンセルしました",
|
||||
"untitled": "無題の予定"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "組織"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "カレンダーをブロック",
|
||||
"description": "不在の期間に予定ありの予定を追加し、その間に会議を入れられないようにします。",
|
||||
"no_access_managed": "このアカウントのカレンダーはブロックできません。書き込み権限がありません。",
|
||||
"no_calendar": "カレンダーをブロックできません。予定を追加できるカレンダーが見つかりませんでした。",
|
||||
"filter_warning": "これをオンにすると、返信が有効な間は自分のフィルタースクリプトがオフになります。"
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "{name} の代わりに返答",
|
||||
"no_write": "{name} の招待は見られますが、代わりに返答することはできません。編集権限を依頼してください。",
|
||||
"also_invited": "あなたも招待されていて、このサーバーではあなたの返答と {name} の返答を区別できません。この招待には {name} 本人が返答する必要があります。",
|
||||
"answered_by": "{who} が返答しました"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "自分の空き時間を {group} と共有",
|
||||
"already": "空き時間は {group} と共有されています",
|
||||
"done": "空き時間を {group} と共有しました。",
|
||||
"error": "空き時間を共有できませんでした。もう一度お試しください。",
|
||||
"group_label": "グループ",
|
||||
"title": "空き時間",
|
||||
"description": "グループのメンバーには予定がある時間だけが見え、予定の内容は見えません。「時間を提案」で使われます。"
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "時間を提案",
|
||||
"searching": "空いている時間を探しています",
|
||||
"slots_title": "Bulwark が確認できた全員が空いている時間",
|
||||
"slot": "{day} {start}~{end}",
|
||||
"none": "今後 10 営業日に空き時間はありません。",
|
||||
"picked": "予定を {slot} に移動しました",
|
||||
"rate_limited": "直前の 1 分間の照会が多すぎます。1 分後にもう一度お試しください。",
|
||||
"unavailable": "このサーバーでは空き時間情報を利用できないため、Bulwark は時間を提案できません。",
|
||||
"not_checked": "確認できなかった人:",
|
||||
"not_checked_item": "{who}:{reason}",
|
||||
"reason_external": "組織外",
|
||||
"reason_unknown": "予定ありの時間は不明",
|
||||
"reason_not_found": "このサーバーに見つかりません",
|
||||
"reason_error": "確認できませんでした",
|
||||
"unknown_note": "Bulwark が予定ありの時間を確認できるのは、共有されたカレンダーだけです。これらの人については、空いているのか共有されていないのか区別できません。",
|
||||
"directory_off": "Bulwark はこのサーバーで人を検索できません。管理者は Stalwart でディレクトリ照会をオンにできます。",
|
||||
"not_looked_up": "このアドレスを検索できません",
|
||||
"found": "見つかった空き時間:{count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "誰か",
|
||||
"shared": "{name} さんが{kind}「{object}」を共有しました",
|
||||
|
||||
@@ -5283,6 +5283,51 @@
|
||||
"cancelled": "{name}님이 \"{title}\"을(를) 취소했습니다",
|
||||
"untitled": "제목 없는 일정"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "조직"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "내 캘린더 차단",
|
||||
"description": "부재 기간에 바쁨 일정을 추가해 그동안 회의가 잡히지 않게 합니다.",
|
||||
"no_access_managed": "이 계정의 캘린더는 차단할 수 없습니다. 쓰기 권한이 없습니다.",
|
||||
"no_calendar": "캘린더를 차단할 수 없습니다. 일정을 추가할 캘린더를 찾지 못했습니다.",
|
||||
"filter_warning": "이 기능을 켜면 응답이 활성화된 동안 내 필터 스크립트가 꺼집니다."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "{name} 대신 응답",
|
||||
"no_write": "{name}의 초대는 볼 수 있지만 대신 응답할 수는 없습니다. 편집 권한을 요청하세요.",
|
||||
"also_invited": "나도 초대되어 있어 이 서버는 내 응답과 {name}의 응답을 구분하지 못합니다. 이 초대는 {name}이(가) 직접 응답해야 합니다.",
|
||||
"answered_by": "{who}이(가) 응답함"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "내 일정 가능 여부를 {group}과(와) 공유",
|
||||
"already": "일정 가능 여부가 {group}과(와) 공유되어 있습니다",
|
||||
"done": "이제 일정 가능 여부가 {group}과(와) 공유됩니다.",
|
||||
"error": "일정 가능 여부를 공유하지 못했습니다. 다시 시도하세요.",
|
||||
"group_label": "그룹",
|
||||
"title": "일정 가능 여부",
|
||||
"description": "그룹의 동료는 내가 바쁜 시간만 보고 일정 내용은 보지 못합니다. 시간 제안에 사용됩니다."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "시간 제안",
|
||||
"searching": "빈 시간을 찾는 중",
|
||||
"slots_title": "Bulwark가 확인할 수 있었던 모든 사람이 비어 있는 시간",
|
||||
"slot": "{day} {start}~{end}",
|
||||
"none": "다음 10 영업일 동안 빈 시간이 없습니다.",
|
||||
"picked": "일정을 {slot}(으)로 옮겼습니다",
|
||||
"rate_limited": "지난 1분 동안 조회가 너무 많았습니다. 1분 후에 다시 시도하세요.",
|
||||
"unavailable": "이 서버에서는 일정 가능 여부를 사용할 수 없어 Bulwark가 시간을 제안할 수 없습니다.",
|
||||
"not_checked": "확인하지 못함:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "조직 외부",
|
||||
"reason_unknown": "바쁜 시간 알 수 없음",
|
||||
"reason_not_found": "이 서버에서 찾을 수 없음",
|
||||
"reason_error": "확인할 수 없음",
|
||||
"unknown_note": "Bulwark는 나와 공유된 캘린더의 바쁜 시간만 볼 수 있습니다. 이 사람들은 비어 있는지 공유하지 않은 것인지 구분할 수 없습니다.",
|
||||
"directory_off": "Bulwark가 이 서버에서 사람을 찾을 수 없습니다. 관리자가 Stalwart에서 디렉터리 조회를 켤 수 있습니다.",
|
||||
"not_looked_up": "이 주소를 찾을 수 없음",
|
||||
"found": "찾은 빈 시간: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "누군가",
|
||||
"shared": "{name}님이 {kind} \"{object}\"을(를) 공유했습니다",
|
||||
|
||||
@@ -5283,6 +5283,51 @@
|
||||
"cancelled": "{name} atcēla “{title}”",
|
||||
"untitled": "Notikums bez nosaukuma"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Organizācija"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Bloķēt manu kalendāru",
|
||||
"description": "Pievieno aizņemtu notikumu prombūtnes laikam, lai tad neviens neplānotu ar jums sapulces.",
|
||||
"no_access_managed": "Šī konta kalendāru nevar bloķēt: jums nav rakstīšanas piekļuves.",
|
||||
"no_calendar": "Kalendāru nevar bloķēt: Bulwark neatrada kalendāru, kur pievienot notikumu.",
|
||||
"filter_warning": "To ieslēdzot, jūsu filtru skripts ir izslēgts, kamēr atbilde ir aktīva."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "Atbildēt {name} vārdā",
|
||||
"no_write": "Jūs redzat {name} ielūgumus, bet nevarat atbildēt viņa vārdā. Palūdziet rediģēšanas piekļuvi.",
|
||||
"also_invited": "Arī jūs esat uzaicināts, un šis serveris nevar atšķirt jūsu atbildi no {name} atbildes. Uz šo jāatbild {name}.",
|
||||
"answered_by": "Atbildēja {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Kopīgot manu brīvs/aizņemts ar {group}",
|
||||
"already": "Jūsu brīvs/aizņemts ir kopīgots ar {group}",
|
||||
"done": "Jūsu brīvs/aizņemts tagad ir kopīgots ar {group}.",
|
||||
"error": "Neizdevās kopīgot brīvs/aizņemts. Mēģiniet vēlreiz.",
|
||||
"group_label": "Grupa",
|
||||
"title": "Brīvs/aizņemts",
|
||||
"description": "Grupas kolēģi redz, kad esat aizņemts, bet ne jūsu notikumus. To izmanto “Ieteikt laikus”."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Ieteikt laikus",
|
||||
"searching": "Meklē brīvus laikus",
|
||||
"slots_title": "Brīvs visiem, kurus Bulwark varēja pārbaudīt",
|
||||
"slot": "{day}, {start} līdz {end}",
|
||||
"none": "Nākamajās 10 darba dienās nav brīva laika.",
|
||||
"picked": "Notikums pārcelts uz {slot}",
|
||||
"rate_limited": "Pēdējā minūtē bija pārāk daudz vaicājumu. Mēģiniet vēlreiz pēc minūtes.",
|
||||
"unavailable": "Šajā serverī nav pieejama brīvs/aizņemts informācija, tāpēc Bulwark nevar ieteikt laikus.",
|
||||
"not_checked": "Nav pārbaudīti:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "ārpus jūsu organizācijas",
|
||||
"reason_unknown": "aizņemtie laiki nav zināmi",
|
||||
"reason_not_found": "šajā serverī nav atrasts",
|
||||
"reason_error": "neizdevās pārbaudīt",
|
||||
"unknown_note": "Bulwark redz aizņemtos laikus tikai kalendāros, kas kopīgoti ar jums. Šiem cilvēkiem tas nevar atšķirt brīvu laiku no nekopīgota kalendāra.",
|
||||
"directory_off": "Bulwark šajā serverī nevar meklēt cilvēkus. Administrators var ieslēgt direktorija vaicājumus Stalwart.",
|
||||
"not_looked_up": "Šo adresi neizdevās atrast",
|
||||
"found": "Atrasti brīvi laiki: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Kāds",
|
||||
"shared": "{name} kopīgoja ar jums {kind} “{object}”",
|
||||
|
||||
@@ -4845,6 +4845,51 @@
|
||||
"cancelled": "{name} \"{title}\"-г цуцаллаа",
|
||||
"untitled": "Нэргүй арга хэмжээ"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Байгууллага"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Миний хуанлийг хаах",
|
||||
"description": "Таныг байхгүй үед завгүй арга хэмжээ нэмж, тэр үед хэн ч уулзалт товлохгүй болгоно.",
|
||||
"no_access_managed": "Энэ дансны хуанлийг хаах боломжгүй: танд бичих эрх алга.",
|
||||
"no_calendar": "Хуанлийг хаах боломжгүй: Bulwark арга хэмжээ нэмэх хуанли олсонгүй.",
|
||||
"filter_warning": "Үүнийг асаавал хариу идэвхтэй байх хугацаанд таны шүүлтүүрийн скрипт унтарна."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "{name}-ийн өмнөөс хариулах",
|
||||
"no_write": "Та {name}-ийн урилгыг харж болох ч өмнөөс нь хариулж чадахгүй. Засах эрх хүснэ үү.",
|
||||
"also_invited": "Таныг ч бас урьсан тул энэ сервер таны болон {name}-ийн хариуг ялгаж чадахгүй. Үүнд {name} өөрөө хариулах ёстой.",
|
||||
"answered_by": "{who} хариулсан"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Миний чөлөөтэй/завгүй цагийг {group}-тэй хуваалцах",
|
||||
"already": "Таны чөлөөтэй/завгүй цаг {group}-тэй хуваалцагдсан",
|
||||
"done": "Таны чөлөөтэй/завгүй цаг одоо {group}-тэй хуваалцагдлаа.",
|
||||
"error": "Чөлөөтэй/завгүй цагийг хуваалцаж чадсангүй. Дахин оролдоно уу.",
|
||||
"group_label": "Бүлэг",
|
||||
"title": "Чөлөөтэй/завгүй",
|
||||
"description": "Бүлгийн хамт олон таныг хэзээ завгүйг хардаг ч арга хэмжээг тань хардаггүй. “Цаг санал болгох” үүнийг ашигладаг."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Цаг санал болгох",
|
||||
"searching": "Чөлөөтэй цаг хайж байна",
|
||||
"slots_title": "Bulwark шалгаж чадсан бүх хүнд чөлөөтэй",
|
||||
"slot": "{day}, {start}-с {end} хүртэл",
|
||||
"none": "Дараагийн 10 ажлын өдөрт чөлөөтэй цаг алга.",
|
||||
"picked": "Арга хэмжээг {slot} руу шилжүүллээ",
|
||||
"rate_limited": "Сүүлийн нэг минутад хэт олон хайлт хийгдлээ. Нэг минутын дараа дахин оролдоно уу.",
|
||||
"unavailable": "Энэ сервер дээр чөлөөтэй/завгүй мэдээлэл байхгүй тул Bulwark цаг санал болгож чадахгүй.",
|
||||
"not_checked": "Шалгаагүй:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "таны байгууллагаас гадуур",
|
||||
"reason_unknown": "завгүй цаг тодорхойгүй",
|
||||
"reason_not_found": "энэ серверээс олдсонгүй",
|
||||
"reason_error": "шалгаж чадсангүй",
|
||||
"unknown_note": "Bulwark зөвхөн тантай хуваалцсан хуанлийн завгүй цагийг хардаг. Эдгээр хүмүүсийн хувьд чөлөөтэй эсвэл хуваалцаагүйг ялгаж чадахгүй.",
|
||||
"directory_off": "Bulwark энэ серверээс хүн хайж чадахгүй. Админ тань Stalwart-д лавлахын хайлтыг асааж болно.",
|
||||
"not_looked_up": "Энэ хаягийг хайж чадсангүй",
|
||||
"found": "Олдсон чөлөөтэй цаг: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Хэн нэгэн",
|
||||
"shared": "{name} танд {kind} \"{object}\"-г хуваалцлаа",
|
||||
|
||||
@@ -4845,6 +4845,51 @@
|
||||
"cancelled": "{name} har avlyst «{title}»",
|
||||
"untitled": "Hendelse uten tittel"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Organisasjon"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Blokker kalenderen min",
|
||||
"description": "Legger inn en opptatt-hendelse mens du er borte, så ingen booker møter med deg da.",
|
||||
"no_access_managed": "Kalenderen til denne kontoen kan ikke blokkeres: du har ikke skrivetilgang til den.",
|
||||
"no_calendar": "Kalenderen din kan ikke blokkeres: Bulwark fant ingen kalender å legge hendelsen i.",
|
||||
"filter_warning": "Slår du dette på, slås ditt eget filterskript av så lenge svaret er aktivt."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "Svar for {name}",
|
||||
"no_write": "Du ser invitasjonene til {name}, men kan ikke svare for dem. Be om redigeringstilgang.",
|
||||
"also_invited": "Du er også invitert, og denne serveren kan ikke skille svaret ditt fra svaret til {name}. {name} må svare på denne selv.",
|
||||
"answered_by": "Besvart av {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Del min ledig/opptatt med {group}",
|
||||
"already": "Din ledig/opptatt er delt med {group}",
|
||||
"done": "Din ledig/opptatt er nå delt med {group}.",
|
||||
"error": "Kunne ikke dele ledig/opptatt. Prøv igjen.",
|
||||
"group_label": "Gruppe",
|
||||
"title": "Ledig/opptatt",
|
||||
"description": "Kolleger i gruppen ser når du er opptatt, ikke hva hendelsene dine er. Foreslå tidspunkter bruker det."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Foreslå tidspunkter",
|
||||
"searching": "Ser etter ledige tidspunkter",
|
||||
"slots_title": "Ledig for alle Bulwark kunne sjekke",
|
||||
"slot": "{day}, {start} til {end}",
|
||||
"none": "Ingen ledig tid de neste 10 arbeidsdagene.",
|
||||
"picked": "Hendelsen er flyttet til {slot}",
|
||||
"rate_limited": "For mange oppslag det siste minuttet. Prøv igjen om et minutt.",
|
||||
"unavailable": "Ledig/opptatt er ikke tilgjengelig på denne serveren, så Bulwark kan ikke foreslå tidspunkter.",
|
||||
"not_checked": "Ikke sjekket:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "utenfor organisasjonen din",
|
||||
"reason_unknown": "opptatte tider ukjent",
|
||||
"reason_not_found": "ikke funnet på denne serveren",
|
||||
"reason_error": "kunne ikke sjekkes",
|
||||
"unknown_note": "Bulwark ser bare opptatte tider i kalendere som er delt med deg. For disse personene kan den ikke skille ledig fra ikke delt.",
|
||||
"directory_off": "Bulwark kan ikke slå opp personer på denne serveren. Administratoren kan slå på katalogspørringer i Stalwart.",
|
||||
"not_looked_up": "Adressen kunne ikke slås opp",
|
||||
"found": "Ledige tidspunkter funnet: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Noen",
|
||||
"shared": "{name} har delt {kind} «{object}» med deg",
|
||||
|
||||
@@ -5283,6 +5283,51 @@
|
||||
"cancelled": "{name} heeft \"{title}\" geannuleerd",
|
||||
"untitled": "Afspraak zonder titel"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Organisatie"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Mijn agenda blokkeren",
|
||||
"description": "Zet een bezette afspraak in je agenda voor de tijd dat je weg bent, zodat niemand dan vergaderingen met je plant.",
|
||||
"no_access_managed": "De agenda van dit account kan niet worden geblokkeerd: je hebt er geen schrijfrechten voor.",
|
||||
"no_calendar": "Je agenda kan niet worden geblokkeerd: Bulwark vond geen agenda om de afspraak in te zetten.",
|
||||
"filter_warning": "Als je dit aanzet, staat je eigen filterscript uit zolang het antwoord actief is."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "Antwoorden namens {name}",
|
||||
"no_write": "Je ziet de uitnodigingen van {name}, maar kunt er niet namens hen op antwoorden. Vraag om bewerkrechten.",
|
||||
"also_invited": "Je bent zelf ook uitgenodigd en deze server kan jouw antwoord niet onderscheiden van dat van {name}. {name} moet deze zelf beantwoorden.",
|
||||
"answered_by": "Beantwoord door {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Mijn vrij/bezet delen met {group}",
|
||||
"already": "Je vrij/bezet is gedeeld met {group}",
|
||||
"done": "Je vrij/bezet is nu gedeeld met {group}.",
|
||||
"error": "Kon je vrij/bezet niet delen. Probeer het opnieuw.",
|
||||
"group_label": "Groep",
|
||||
"title": "Vrij/bezet",
|
||||
"description": "Collega’s in de groep zien wanneer je bezet bent, niet wat je afspraken zijn. ‘Tijden voorstellen’ gebruikt dit."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Tijden voorstellen",
|
||||
"searching": "Vrije tijden zoeken",
|
||||
"slots_title": "Vrij voor iedereen die Bulwark kon controleren",
|
||||
"slot": "{day}, {start} tot {end}",
|
||||
"none": "Geen vrije tijd in de komende 10 werkdagen.",
|
||||
"picked": "Afspraak verplaatst naar {slot}",
|
||||
"rate_limited": "Te veel opvragingen in de afgelopen minuut. Probeer het over een minuut opnieuw.",
|
||||
"unavailable": "Vrij/bezet is niet beschikbaar op deze server, dus Bulwark kan geen tijden voorstellen.",
|
||||
"not_checked": "Niet gecontroleerd:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "buiten je organisatie",
|
||||
"reason_unknown": "bezette tijden onbekend",
|
||||
"reason_not_found": "niet gevonden op deze server",
|
||||
"reason_error": "kon niet worden gecontroleerd",
|
||||
"unknown_note": "Bulwark ziet bezette tijden alleen in agenda’s die met je zijn gedeeld. Bij deze mensen kan het vrij niet onderscheiden van niet gedeeld.",
|
||||
"directory_off": "Bulwark kan op deze server geen mensen opzoeken. Je beheerder kan directoryquery’s aanzetten in Stalwart.",
|
||||
"not_looked_up": "Dit adres kon niet worden opgezocht",
|
||||
"found": "Vrije tijden gevonden: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Iemand",
|
||||
"shared": "{name} heeft {kind} \"{object}\" met je gedeeld",
|
||||
|
||||
@@ -5283,6 +5283,51 @@
|
||||
"cancelled": "{name} odwołał(a) „{title}”",
|
||||
"untitled": "Wydarzenie bez tytułu"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Organizacja"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Zablokuj mój kalendarz",
|
||||
"description": "Dodaje zajęte wydarzenie na czas Twojej nieobecności, żeby nikt nie umawiał wtedy z Tobą spotkań.",
|
||||
"no_access_managed": "Nie można zablokować kalendarza tego konta: nie masz do niego prawa zapisu.",
|
||||
"no_calendar": "Nie można zablokować kalendarza: Bulwark nie znalazł kalendarza, do którego można dodać wydarzenie.",
|
||||
"filter_warning": "Włączenie tego wyłącza Twój własny skrypt filtrów, dopóki odpowiedź jest aktywna."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "Odpowiedz w imieniu: {name}",
|
||||
"no_write": "Widzisz zaproszenia użytkownika {name}, ale nie możesz za niego odpowiadać. Poproś o prawo do edycji.",
|
||||
"also_invited": "Też jesteś zaproszony, a ten serwer nie odróżni Twojej odpowiedzi od odpowiedzi {name}. Na to zaproszenie musi odpowiedzieć {name}.",
|
||||
"answered_by": "Odpowiedź od: {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Udostępnij moją dostępność grupie {group}",
|
||||
"already": "Twoja dostępność jest udostępniona grupie {group}",
|
||||
"done": "Twoja dostępność jest teraz udostępniona grupie {group}.",
|
||||
"error": "Nie udało się udostępnić dostępności. Spróbuj ponownie.",
|
||||
"group_label": "Grupa",
|
||||
"title": "Dostępność",
|
||||
"description": "Współpracownicy z grupy widzą, kiedy jesteś zajęty, ale nie widzą Twoich wydarzeń. Korzysta z tego funkcja proponowania terminów."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Zaproponuj terminy",
|
||||
"searching": "Szukanie wolnych terminów",
|
||||
"slots_title": "Wolne dla wszystkich, których Bulwark mógł sprawdzić",
|
||||
"slot": "{day}, od {start} do {end}",
|
||||
"none": "Brak wolnego czasu w ciągu najbliższych 10 dni roboczych.",
|
||||
"picked": "Wydarzenie przeniesione na {slot}",
|
||||
"rate_limited": "Za dużo zapytań w ostatniej minucie. Spróbuj ponownie za minutę.",
|
||||
"unavailable": "Informacje o dostępności nie są dostępne na tym serwerze, więc Bulwark nie może zaproponować terminów.",
|
||||
"not_checked": "Nie sprawdzono:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "spoza Twojej organizacji",
|
||||
"reason_unknown": "zajęte terminy nieznane",
|
||||
"reason_not_found": "nie znaleziono na tym serwerze",
|
||||
"reason_error": "nie udało się sprawdzić",
|
||||
"unknown_note": "Bulwark widzi zajęte terminy tylko w kalendarzach udostępnionych Tobie. U tych osób nie odróżni wolnego czasu od braku udostępnienia.",
|
||||
"directory_off": "Bulwark nie może wyszukiwać osób na tym serwerze. Administrator może włączyć zapytania do katalogu w Stalwart.",
|
||||
"not_looked_up": "Nie udało się wyszukać adresu",
|
||||
"found": "Znalezione wolne terminy: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Ktoś",
|
||||
"shared": "{name} udostępnił(a) Ci {kind} „{object}”",
|
||||
|
||||
@@ -5283,6 +5283,51 @@
|
||||
"cancelled": "{name} cancelou \"{title}\"",
|
||||
"untitled": "Evento sem título"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Organização"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Bloquear o meu calendário",
|
||||
"description": "Adiciona um evento ocupado durante a sua ausência, para que ninguém marque reuniões consigo nessa altura.",
|
||||
"no_access_managed": "Não é possível bloquear o calendário desta conta: não tem acesso de escrita.",
|
||||
"no_calendar": "Não é possível bloquear o calendário: o Bulwark não encontrou um calendário para adicionar o evento.",
|
||||
"filter_warning": "Ativar isto desliga o seu próprio script de filtros enquanto a resposta estiver ativa."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "Responder por {name}",
|
||||
"no_write": "Pode ver os convites de {name}, mas não responder por essa pessoa. Peça acesso de edição.",
|
||||
"also_invited": "Também foi convidado e este servidor não distingue a sua resposta da de {name}. {name} tem de responder a esta.",
|
||||
"answered_by": "Respondido por {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Partilhar a minha disponibilidade com {group}",
|
||||
"already": "A sua disponibilidade está partilhada com {group}",
|
||||
"done": "A sua disponibilidade está agora partilhada com {group}.",
|
||||
"error": "Não foi possível partilhar a sua disponibilidade. Tente novamente.",
|
||||
"group_label": "Grupo",
|
||||
"title": "Disponibilidade",
|
||||
"description": "Os colegas do grupo veem quando está ocupado, não quais são os seus eventos. «Sugerir horários» usa isto."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Sugerir horários",
|
||||
"searching": "A procurar horários livres",
|
||||
"slots_title": "Livre para todos os que o Bulwark conseguiu verificar",
|
||||
"slot": "{day}, das {start} às {end}",
|
||||
"none": "Não há tempo livre nos próximos 10 dias úteis.",
|
||||
"picked": "Evento movido para {slot}",
|
||||
"rate_limited": "Demasiadas consultas no último minuto. Tente novamente daqui a um minuto.",
|
||||
"unavailable": "A disponibilidade não está acessível neste servidor, por isso o Bulwark não pode sugerir horários.",
|
||||
"not_checked": "Não verificados:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "fora da sua organização",
|
||||
"reason_unknown": "horas ocupadas desconhecidas",
|
||||
"reason_not_found": "não encontrado neste servidor",
|
||||
"reason_error": "não foi possível verificar",
|
||||
"unknown_note": "O Bulwark só vê horas ocupadas nos calendários partilhados consigo. Para estas pessoas não consegue distinguir livre de não partilhado.",
|
||||
"directory_off": "O Bulwark não consegue procurar pessoas neste servidor. O administrador pode ativar as consultas ao diretório no Stalwart.",
|
||||
"not_looked_up": "Não foi possível procurar este endereço",
|
||||
"found": "Horários livres encontrados: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Alguém",
|
||||
"shared": "{name} partilhou consigo {kind} \"{object}\"",
|
||||
|
||||
@@ -4845,6 +4845,51 @@
|
||||
"cancelled": "{name} a anulat „{title}”",
|
||||
"untitled": "Eveniment fără titlu"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Organizație"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Blochează-mi calendarul",
|
||||
"description": "Adaugă un eveniment ocupat pe durata absenței, ca nimeni să nu vă programeze ședințe atunci.",
|
||||
"no_access_managed": "Calendarul acestui cont nu poate fi blocat: nu aveți acces de scriere la el.",
|
||||
"no_calendar": "Calendarul nu poate fi blocat: Bulwark nu a găsit un calendar în care să adauge evenimentul.",
|
||||
"filter_warning": "Activarea oprește scriptul dvs. de filtre cât timp răspunsul este activ."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "Răspunde în numele {name}",
|
||||
"no_write": "Vedeți invitațiile lui {name}, dar nu puteți răspunde în numele său. Cereți acces de editare.",
|
||||
"also_invited": "Sunteți și dvs. invitat, iar acest server nu poate deosebi răspunsul dvs. de cel al lui {name}. {name} trebuie să răspundă la aceasta.",
|
||||
"answered_by": "Răspuns de {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Partajează disponibilitatea mea cu {group}",
|
||||
"already": "Disponibilitatea dvs. este partajată cu {group}",
|
||||
"done": "Disponibilitatea dvs. este acum partajată cu {group}.",
|
||||
"error": "Disponibilitatea nu a putut fi partajată. Încercați din nou.",
|
||||
"group_label": "Grup",
|
||||
"title": "Disponibilitate",
|
||||
"description": "Colegii din grup văd când sunteți ocupat, nu ce evenimente aveți. „Sugerează ore” folosește asta."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Sugerează ore",
|
||||
"searching": "Se caută ore libere",
|
||||
"slots_title": "Liber pentru toți cei pe care Bulwark i-a putut verifica",
|
||||
"slot": "{day}, de la {start} la {end}",
|
||||
"none": "Nu există timp liber în următoarele 10 zile lucrătoare.",
|
||||
"picked": "Eveniment mutat la {slot}",
|
||||
"rate_limited": "Prea multe interogări în ultimul minut. Încercați din nou peste un minut.",
|
||||
"unavailable": "Disponibilitatea nu este accesibilă pe acest server, așa că Bulwark nu poate sugera ore.",
|
||||
"not_checked": "Neverificați:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "din afara organizației dvs.",
|
||||
"reason_unknown": "ore ocupate necunoscute",
|
||||
"reason_not_found": "negăsit pe acest server",
|
||||
"reason_error": "nu a putut fi verificat",
|
||||
"unknown_note": "Bulwark vede orele ocupate doar în calendarele partajate cu dvs. Pentru aceste persoane nu poate deosebi liber de nepartajat.",
|
||||
"directory_off": "Bulwark nu poate căuta persoane pe acest server. Administratorul poate activa interogările de director în Stalwart.",
|
||||
"not_looked_up": "Adresa nu a putut fi căutată",
|
||||
"found": "Ore libere găsite: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Cineva",
|
||||
"shared": "{name} a partajat cu tine {kind} „{object}”",
|
||||
|
||||
@@ -5283,6 +5283,51 @@
|
||||
"cancelled": "{name} отменил(а) «{title}»",
|
||||
"untitled": "Событие без названия"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Организация"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Занять мой календарь",
|
||||
"description": "Добавляет событие «занят» на время отсутствия, чтобы вам не назначали встречи.",
|
||||
"no_access_managed": "Календарь этой учётной записи нельзя занять: у вас нет доступа на запись.",
|
||||
"no_calendar": "Не удаётся занять календарь: Bulwark не нашёл календарь для события.",
|
||||
"filter_warning": "Если включить, ваш собственный скрипт фильтров будет выключен, пока ответ активен."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "Ответить за {name}",
|
||||
"no_write": "Вы видите приглашения {name}, но не можете отвечать за этого человека. Попросите доступ на изменение.",
|
||||
"also_invited": "Вы тоже приглашены, и этот сервер не отличит ваш ответ от ответа {name}. На это приглашение должен ответить {name}.",
|
||||
"answered_by": "Ответил(а): {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Открыть мою занятость для {group}",
|
||||
"already": "Ваша занятость открыта для {group}",
|
||||
"done": "Теперь ваша занятость открыта для {group}.",
|
||||
"error": "Не удалось открыть занятость. Попробуйте ещё раз.",
|
||||
"group_label": "Группа",
|
||||
"title": "Занятость",
|
||||
"description": "Коллеги из группы видят, когда вы заняты, но не видят ваши события. Это использует функция «Предложить время»."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Предложить время",
|
||||
"searching": "Поиск свободного времени",
|
||||
"slots_title": "Свободно у всех, кого Bulwark смог проверить",
|
||||
"slot": "{day}, с {start} до {end}",
|
||||
"none": "В ближайшие 10 рабочих дней свободного времени нет.",
|
||||
"picked": "Событие перенесено на {slot}",
|
||||
"rate_limited": "Слишком много запросов за последнюю минуту. Повторите через минуту.",
|
||||
"unavailable": "Сведения о занятости недоступны на этом сервере, поэтому Bulwark не может предложить время.",
|
||||
"not_checked": "Не проверены:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "вне вашей организации",
|
||||
"reason_unknown": "занятость неизвестна",
|
||||
"reason_not_found": "не найден на этом сервере",
|
||||
"reason_error": "не удалось проверить",
|
||||
"unknown_note": "Bulwark видит занятость только в календарях, к которым вам открыт доступ. Для этих людей он не может отличить «свободно» от «нет доступа».",
|
||||
"directory_off": "Bulwark не может искать людей на этом сервере. Администратор может включить запросы к каталогу в Stalwart.",
|
||||
"not_looked_up": "Не удалось найти этот адрес",
|
||||
"found": "Найдено свободных окон: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Кто-то",
|
||||
"shared": "{name} поделился(ась) с вами {kind} «{object}»",
|
||||
|
||||
@@ -4845,6 +4845,51 @@
|
||||
"cancelled": "{name} zrušil(a) „{title}“",
|
||||
"untitled": "Udalosť bez názvu"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Organizácia"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Zablokovať môj kalendár",
|
||||
"description": "Pridá obsadenú udalosť na čas vašej neprítomnosti, aby vám vtedy nikto neplánoval stretnutia.",
|
||||
"no_access_managed": "Kalendár tohto účtu nemožno zablokovať: nemáte doň prístup na zápis.",
|
||||
"no_calendar": "Kalendár nemožno zablokovať: Bulwark nenašiel kalendár, kam udalosť pridať.",
|
||||
"filter_warning": "Zapnutím sa vypne váš vlastný skript filtrov, kým je odpoveď aktívna."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "Odpovedať za: {name}",
|
||||
"no_write": "Vidíte pozvánky používateľa {name}, ale nemôžete za neho odpovedať. Požiadajte o prístup na úpravy.",
|
||||
"also_invited": "Ste pozvaní aj vy a tento server nerozlíši vašu odpoveď od odpovede používateľa {name}. Na túto musí odpovedať {name}.",
|
||||
"answered_by": "Odpovedal(a): {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Zdieľať môj voľný čas so skupinou {group}",
|
||||
"already": "Váš voľný čas je zdieľaný so skupinou {group}",
|
||||
"done": "Váš voľný čas je teraz zdieľaný so skupinou {group}.",
|
||||
"error": "Voľný čas sa nepodarilo zdieľať. Skúste to znova.",
|
||||
"group_label": "Skupina",
|
||||
"title": "Voľno/obsadené",
|
||||
"description": "Kolegovia v skupine vidia, kedy ste obsadení, nie aké máte udalosti. Používa to funkcia Navrhnúť časy."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Navrhnúť časy",
|
||||
"searching": "Hľadajú sa voľné časy",
|
||||
"slots_title": "Voľno pre všetkých, ktorých Bulwark mohol overiť",
|
||||
"slot": "{day}, {start} až {end}",
|
||||
"none": "V nasledujúcich 10 pracovných dňoch nie je voľný čas.",
|
||||
"picked": "Udalosť presunutá na {slot}",
|
||||
"rate_limited": "V poslednej minúte bolo príliš veľa dopytov. Skúste to znova o minútu.",
|
||||
"unavailable": "Informácie o voľnom čase nie sú na tomto serveri dostupné, takže Bulwark nemôže navrhnúť časy.",
|
||||
"not_checked": "Neoverené:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "mimo vašej organizácie",
|
||||
"reason_unknown": "obsadené časy neznáme",
|
||||
"reason_not_found": "na tomto serveri sa nenašiel",
|
||||
"reason_error": "nepodarilo sa overiť",
|
||||
"unknown_note": "Bulwark vidí obsadené časy len v kalendároch, ktoré s vami niekto zdieľa. Pri týchto ľuďoch nerozlíši voľno od nezdieľaného kalendára.",
|
||||
"directory_off": "Bulwark na tomto serveri nemôže vyhľadávať ľudí. Správca môže v Stalwarte zapnúť dopyty do adresára.",
|
||||
"not_looked_up": "Adresu sa nepodarilo vyhľadať",
|
||||
"found": "Nájdené voľné časy: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Niekto",
|
||||
"shared": "{name} s vami zdieľa {kind} „{object}“",
|
||||
|
||||
@@ -2185,6 +2185,51 @@
|
||||
"cancelled": "{name} ยกเลิกกิจกรรม “{title}”",
|
||||
"untitled": "กิจกรรมที่ไม่มีชื่อ"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "องค์กร"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "บล็อกปฏิทินของฉัน",
|
||||
"description": "เพิ่มกิจกรรมไม่ว่างในช่วงที่คุณไม่อยู่ เพื่อไม่ให้ใครนัดประชุมกับคุณในเวลานั้น",
|
||||
"no_access_managed": "บล็อกปฏิทินของบัญชีนี้ไม่ได้ เพราะคุณไม่มีสิทธิ์เขียน",
|
||||
"no_calendar": "บล็อกปฏิทินไม่ได้ Bulwark ไม่พบปฏิทินสำหรับเพิ่มกิจกรรม",
|
||||
"filter_warning": "การเปิดใช้จะปิดสคริปต์ตัวกรองของคุณเองตลอดช่วงที่การตอบกลับทำงาน"
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "ตอบแทน {name}",
|
||||
"no_write": "คุณเห็นคำเชิญของ {name} แต่ตอบแทนไม่ได้ โปรดขอสิทธิ์แก้ไข",
|
||||
"also_invited": "คุณได้รับเชิญด้วย และเซิร์ฟเวอร์นี้แยกคำตอบของคุณกับของ {name} ไม่ได้ {name} ต้องตอบเอง",
|
||||
"answered_by": "ตอบโดย {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "แชร์เวลาว่าง/ไม่ว่างของฉันกับ {group}",
|
||||
"already": "แชร์เวลาว่าง/ไม่ว่างของคุณกับ {group} แล้ว",
|
||||
"done": "แชร์เวลาว่าง/ไม่ว่างของคุณกับ {group} แล้ว",
|
||||
"error": "แชร์เวลาว่าง/ไม่ว่างไม่สำเร็จ ลองอีกครั้ง",
|
||||
"group_label": "กลุ่ม",
|
||||
"title": "ว่าง/ไม่ว่าง",
|
||||
"description": "เพื่อนร่วมงานในกลุ่มเห็นว่าคุณไม่ว่างเมื่อใด แต่ไม่เห็นรายละเอียดกิจกรรม ฟีเจอร์แนะนำเวลาใช้ข้อมูลนี้"
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "แนะนำเวลา",
|
||||
"searching": "กำลังหาเวลาว่าง",
|
||||
"slots_title": "ว่างสำหรับทุกคนที่ Bulwark ตรวจสอบได้",
|
||||
"slot": "{day} {start} ถึง {end}",
|
||||
"none": "ไม่มีเวลาว่างใน 10 วันทำงานถัดไป",
|
||||
"picked": "ย้ายกิจกรรมไปที่ {slot} แล้ว",
|
||||
"rate_limited": "มีการค้นหามากเกินไปในหนึ่งนาทีที่ผ่านมา ลองอีกครั้งในอีกหนึ่งนาที",
|
||||
"unavailable": "เซิร์ฟเวอร์นี้ไม่มีข้อมูลว่าง/ไม่ว่าง Bulwark จึงแนะนำเวลาไม่ได้",
|
||||
"not_checked": "ไม่ได้ตรวจสอบ:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "อยู่นอกองค์กรของคุณ",
|
||||
"reason_unknown": "ไม่ทราบเวลาที่ไม่ว่าง",
|
||||
"reason_not_found": "ไม่พบในเซิร์ฟเวอร์นี้",
|
||||
"reason_error": "ตรวจสอบไม่ได้",
|
||||
"unknown_note": "Bulwark เห็นเวลาที่ไม่ว่างเฉพาะในปฏิทินที่แชร์กับคุณ สำหรับคนเหล่านี้จึงแยกไม่ออกว่าว่างหรือไม่ได้แชร์",
|
||||
"directory_off": "Bulwark ค้นหาบุคคลในเซิร์ฟเวอร์นี้ไม่ได้ ผู้ดูแลระบบเปิดการค้นหาไดเรกทอรีได้ใน Stalwart",
|
||||
"not_looked_up": "ค้นหาที่อยู่นี้ไม่ได้",
|
||||
"found": "พบเวลาว่าง: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "บุคคลอื่น",
|
||||
"shared": "{name} แชร์ {kind} “{object}” กับคุณ",
|
||||
|
||||
@@ -4845,6 +4845,51 @@
|
||||
"cancelled": "{name} \"{title}\" etkinliğini iptal etti",
|
||||
"untitled": "Başlıksız etkinlik"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Kuruluş"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Takvimimi kapat",
|
||||
"description": "Uzakta olduğunuz süre için meşgul bir etkinlik ekler, böylece o sırada kimse sizinle toplantı ayarlamaz.",
|
||||
"no_access_managed": "Bu hesabın takvimi kapatılamaz: yazma erişiminiz yok.",
|
||||
"no_calendar": "Takviminiz kapatılamıyor: Bulwark etkinliği ekleyecek bir takvim bulamadı.",
|
||||
"filter_warning": "Bunu açmak, yanıt etkin olduğu sürece kendi filtre betiğinizi kapatır."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "{name} adına yanıtla",
|
||||
"no_write": "{name} kişisinin davetlerini görebilirsiniz ama onun adına yanıtlayamazsınız. Düzenleme erişimi isteyin.",
|
||||
"also_invited": "Siz de davetlisiniz ve bu sunucu yanıtınızı {name} kişisinin yanıtından ayıramıyor. Bunu {name} yanıtlamalı.",
|
||||
"answered_by": "Yanıtlayan: {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Müsaitlik durumumu {group} ile paylaş",
|
||||
"already": "Müsaitlik durumunuz {group} ile paylaşılıyor",
|
||||
"done": "Müsaitlik durumunuz artık {group} ile paylaşılıyor.",
|
||||
"error": "Müsaitlik durumunuz paylaşılamadı. Yeniden deneyin.",
|
||||
"group_label": "Grup",
|
||||
"title": "Müsaitlik",
|
||||
"description": "Gruptaki iş arkadaşlarınız ne zaman meşgul olduğunuzu görür, etkinliklerinizi görmez. Saat öner bunu kullanır."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Saat öner",
|
||||
"searching": "Boş saatler aranıyor",
|
||||
"slots_title": "Bulwark’ın kontrol edebildiği herkes için boş",
|
||||
"slot": "{day}, {start} - {end}",
|
||||
"none": "Önümüzdeki 10 iş gününde boş zaman yok.",
|
||||
"picked": "Etkinlik {slot} zamanına taşındı",
|
||||
"rate_limited": "Son bir dakikada çok fazla sorgu yapıldı. Bir dakika sonra yeniden deneyin.",
|
||||
"unavailable": "Bu sunucuda müsaitlik bilgisi yok, bu yüzden Bulwark saat öneremiyor.",
|
||||
"not_checked": "Kontrol edilmedi:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "kuruluşunuzun dışında",
|
||||
"reason_unknown": "meşgul saatler bilinmiyor",
|
||||
"reason_not_found": "bu sunucuda bulunamadı",
|
||||
"reason_error": "kontrol edilemedi",
|
||||
"unknown_note": "Bulwark meşgul saatleri yalnızca sizinle paylaşılan takvimlerde görür. Bu kişiler için boş olmayı paylaşılmamaktan ayıramaz.",
|
||||
"directory_off": "Bulwark bu sunucuda kişi arayamıyor. Yöneticiniz Stalwart’ta dizin sorgularını açabilir.",
|
||||
"not_looked_up": "Bu adres aranamadı",
|
||||
"found": "Bulunan boş saatler: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Birisi",
|
||||
"shared": "{name}, \"{object}\" {kind} sizinle paylaştı",
|
||||
|
||||
@@ -5283,6 +5283,51 @@
|
||||
"cancelled": "{name} скасував(ла) «{title}»",
|
||||
"untitled": "Подія без назви"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Організація"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Заблокувати мій календар",
|
||||
"description": "Додає подію «зайнято» на час вашої відсутності, щоб вам тоді не призначали зустрічей.",
|
||||
"no_access_managed": "Календар цього облікового запису не можна заблокувати: у вас немає доступу на запис.",
|
||||
"no_calendar": "Не вдається заблокувати календар: Bulwark не знайшов календаря для події.",
|
||||
"filter_warning": "Якщо це ввімкнути, ваш власний скрипт фільтрів буде вимкнено, поки відповідь активна."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "Відповісти за {name}",
|
||||
"no_write": "Ви бачите запрошення {name}, але не можете відповідати за цю людину. Попросіть доступ на редагування.",
|
||||
"also_invited": "Вас теж запрошено, і цей сервер не відрізнить вашу відповідь від відповіді {name}. На це запрошення має відповісти {name}.",
|
||||
"answered_by": "Відповідь від: {who}"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Поділитися моєю зайнятістю з {group}",
|
||||
"already": "Вашою зайнятістю поділено з {group}",
|
||||
"done": "Тепер вашою зайнятістю поділено з {group}.",
|
||||
"error": "Не вдалося поділитися зайнятістю. Спробуйте ще раз.",
|
||||
"group_label": "Група",
|
||||
"title": "Зайнятість",
|
||||
"description": "Колеги з групи бачать, коли ви зайняті, але не бачать ваших подій. Цим користується «Запропонувати час»."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Запропонувати час",
|
||||
"searching": "Пошук вільного часу",
|
||||
"slots_title": "Вільно в усіх, кого Bulwark зміг перевірити",
|
||||
"slot": "{day}, з {start} до {end}",
|
||||
"none": "У найближчі 10 робочих днів вільного часу немає.",
|
||||
"picked": "Подію перенесено на {slot}",
|
||||
"rate_limited": "Забагато запитів за останню хвилину. Спробуйте ще раз за хвилину.",
|
||||
"unavailable": "Відомості про зайнятість на цьому сервері недоступні, тому Bulwark не може запропонувати час.",
|
||||
"not_checked": "Не перевірено:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "поза вашою організацією",
|
||||
"reason_unknown": "зайнятість невідома",
|
||||
"reason_not_found": "не знайдено на цьому сервері",
|
||||
"reason_error": "не вдалося перевірити",
|
||||
"unknown_note": "Bulwark бачить зайнятість лише в календарях, якими з вами поділилися. Для цих людей він не відрізнить «вільно» від «немає доступу».",
|
||||
"directory_off": "Bulwark не може шукати людей на цьому сервері. Адміністратор може ввімкнути запити до каталогу в Stalwart.",
|
||||
"not_looked_up": "Не вдалося знайти цю адресу",
|
||||
"found": "Знайдено вільних проміжків: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Хтось",
|
||||
"shared": "{name} поділився(лася) з вами {kind} «{object}»",
|
||||
|
||||
@@ -4857,6 +4857,51 @@
|
||||
"cancelled": "{name} đã hủy \"{title}\"",
|
||||
"untitled": "Sự kiện không có tiêu đề"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Tổ chức"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Chặn lịch của tôi",
|
||||
"description": "Thêm một sự kiện bận trong thời gian bạn vắng mặt để không ai đặt cuộc họp với bạn lúc đó.",
|
||||
"no_access_managed": "Không thể chặn lịch của tài khoản này: bạn không có quyền ghi.",
|
||||
"no_calendar": "Không thể chặn lịch: Bulwark không tìm thấy lịch để thêm sự kiện.",
|
||||
"filter_warning": "Bật tính năng này sẽ tắt tập lệnh lọc của riêng bạn trong thời gian trả lời tự động bật."
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "Trả lời thay {name}",
|
||||
"no_write": "Bạn xem được lời mời của {name} nhưng không thể trả lời thay. Hãy xin quyền chỉnh sửa.",
|
||||
"also_invited": "Bạn cũng được mời, và máy chủ này không phân biệt được câu trả lời của bạn với của {name}. {name} phải tự trả lời lời mời này.",
|
||||
"answered_by": "Đã được {who} trả lời"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "Chia sẻ lịch rảnh/bận của tôi với {group}",
|
||||
"already": "Lịch rảnh/bận của bạn đã được chia sẻ với {group}",
|
||||
"done": "Đã chia sẻ lịch rảnh/bận của bạn với {group}.",
|
||||
"error": "Không chia sẻ được lịch rảnh/bận. Hãy thử lại.",
|
||||
"group_label": "Nhóm",
|
||||
"title": "Rảnh/bận",
|
||||
"description": "Đồng nghiệp trong nhóm thấy khi nào bạn bận, không thấy nội dung sự kiện. Tính năng gợi ý thời gian dùng thông tin này."
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "Gợi ý thời gian",
|
||||
"searching": "Đang tìm thời gian trống",
|
||||
"slots_title": "Trống với mọi người mà Bulwark kiểm tra được",
|
||||
"slot": "{day}, {start} đến {end}",
|
||||
"none": "Không có thời gian trống trong 10 ngày làm việc tới.",
|
||||
"picked": "Đã dời sự kiện sang {slot}",
|
||||
"rate_limited": "Quá nhiều lượt tra cứu trong phút vừa qua. Hãy thử lại sau một phút.",
|
||||
"unavailable": "Máy chủ này không có thông tin rảnh/bận, nên Bulwark không thể gợi ý thời gian.",
|
||||
"not_checked": "Chưa kiểm tra:",
|
||||
"not_checked_item": "{who}: {reason}",
|
||||
"reason_external": "ngoài tổ chức của bạn",
|
||||
"reason_unknown": "không rõ giờ bận",
|
||||
"reason_not_found": "không tìm thấy trên máy chủ này",
|
||||
"reason_error": "không kiểm tra được",
|
||||
"unknown_note": "Bulwark chỉ thấy giờ bận trong các lịch được chia sẻ với bạn. Với những người này, nó không phân biệt được rảnh hay chưa chia sẻ.",
|
||||
"directory_off": "Bulwark không thể tra cứu người trên máy chủ này. Quản trị viên có thể bật truy vấn thư mục trong Stalwart.",
|
||||
"not_looked_up": "Không tra cứu được địa chỉ này",
|
||||
"found": "Số khung giờ trống tìm thấy: {count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "Ai đó",
|
||||
"shared": "{name} đã chia sẻ {kind} \"{object}\" với bạn",
|
||||
|
||||
@@ -4845,6 +4845,51 @@
|
||||
"cancelled": "{name} 取消了「{title}」",
|
||||
"untitled": "未命名活動"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "組織"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "佔用我的行事曆",
|
||||
"description": "在你外出期間新增一個忙碌活動,這樣別人不會在這段時間約你開會。",
|
||||
"no_access_managed": "無法佔用此帳號的行事曆:你沒有寫入權限。",
|
||||
"no_calendar": "無法佔用你的行事曆:Bulwark 找不到可以新增活動的行事曆。",
|
||||
"filter_warning": "開啟後,在自動回覆生效期間你自己的過濾指令碼會被關閉。"
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "代表 {name} 回覆",
|
||||
"no_write": "你能看到 {name} 的邀請,但不能代為回覆。請向對方申請編輯權限。",
|
||||
"also_invited": "你也被邀請了,此伺服器無法區分你和 {name} 的回覆。這一項需要 {name} 自己回覆。",
|
||||
"answered_by": "由 {who} 回覆"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "與 {group} 共用我的忙碌/空閒時間",
|
||||
"already": "你的忙碌/空閒時間已與 {group} 共用",
|
||||
"done": "你的忙碌/空閒時間現已與 {group} 共用。",
|
||||
"error": "無法共用你的忙碌/空閒時間,請再試一次。",
|
||||
"group_label": "群組",
|
||||
"title": "忙碌/空閒",
|
||||
"description": "群組中的同事能看到你何時忙碌,看不到活動內容。「建議時間」會用到它。"
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "建議時間",
|
||||
"searching": "正在尋找空閒時間",
|
||||
"slots_title": "Bulwark 能查到的所有人都有空",
|
||||
"slot": "{day} {start} 至 {end}",
|
||||
"none": "未來 10 個工作天內沒有空閒時間。",
|
||||
"picked": "活動已移到 {slot}",
|
||||
"rate_limited": "過去一分鐘內查詢過多,請一分鐘後再試。",
|
||||
"unavailable": "此伺服器不提供忙碌/空閒資訊,Bulwark 無法建議時間。",
|
||||
"not_checked": "未檢查:",
|
||||
"not_checked_item": "{who}:{reason}",
|
||||
"reason_external": "組織外部",
|
||||
"reason_unknown": "忙碌時間未知",
|
||||
"reason_not_found": "在此伺服器上找不到",
|
||||
"reason_error": "無法檢查",
|
||||
"unknown_note": "Bulwark 只能看到與你共用的行事曆中的忙碌時間。對這些人,它無法區分有空和未共用。",
|
||||
"directory_off": "Bulwark 無法在此伺服器上查詢使用者。管理員可以在 Stalwart 中開啟目錄查詢。",
|
||||
"not_looked_up": "無法查詢此地址",
|
||||
"found": "找到的空閒時間:{count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "有人",
|
||||
"shared": "{name} 與您共用了{kind}「{object}」",
|
||||
|
||||
@@ -5283,6 +5283,51 @@
|
||||
"cancelled": "{name} 取消了“{title}”",
|
||||
"untitled": "无标题活动"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "组织"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "占用我的日历",
|
||||
"description": "在你外出期间添加一个忙碌活动,这样别人不会在这段时间约你开会。",
|
||||
"no_access_managed": "无法占用此账户的日历:你没有写入权限。",
|
||||
"no_calendar": "无法占用你的日历:Bulwark 找不到可以添加活动的日历。",
|
||||
"filter_warning": "开启后,在自动回复生效期间你自己的过滤脚本会被关闭。"
|
||||
},
|
||||
"calendar_delegate": {
|
||||
"answer_for": "代表 {name} 回复",
|
||||
"no_write": "你能看到 {name} 的邀请,但不能代为回复。请向对方申请编辑权限。",
|
||||
"also_invited": "你也被邀请了,此服务器无法区分你和 {name} 的回复。这一项需要 {name} 自己回复。",
|
||||
"answered_by": "由 {who} 回复"
|
||||
},
|
||||
"free_busy_share": {
|
||||
"button": "与 {group} 共享我的忙闲时间",
|
||||
"already": "你的忙闲时间已与 {group} 共享",
|
||||
"done": "你的忙闲时间现已与 {group} 共享。",
|
||||
"error": "无法共享你的忙闲时间,请重试。",
|
||||
"group_label": "群组",
|
||||
"title": "忙闲",
|
||||
"description": "群组中的同事能看到你何时忙碌,看不到活动内容。“建议时间”会用到它。"
|
||||
},
|
||||
"find_time": {
|
||||
"suggest": "建议时间",
|
||||
"searching": "正在查找空闲时间",
|
||||
"slots_title": "Bulwark 能查到的所有人都有空",
|
||||
"slot": "{day} {start} 至 {end}",
|
||||
"none": "未来 10 个工作日内没有空闲时间。",
|
||||
"picked": "活动已移到 {slot}",
|
||||
"rate_limited": "过去一分钟内查询过多,请一分钟后再试。",
|
||||
"unavailable": "此服务器不提供忙闲信息,Bulwark 无法建议时间。",
|
||||
"not_checked": "未检查:",
|
||||
"not_checked_item": "{who}:{reason}",
|
||||
"reason_external": "组织外部",
|
||||
"reason_unknown": "忙碌时间未知",
|
||||
"reason_not_found": "在此服务器上找不到",
|
||||
"reason_error": "无法检查",
|
||||
"unknown_note": "Bulwark 只能看到与你共享的日历中的忙碌时间。对这些人,它无法区分有空和未共享。",
|
||||
"directory_off": "Bulwark 无法在此服务器上查找用户。管理员可以在 Stalwart 中开启目录查询。",
|
||||
"not_looked_up": "无法查找此地址",
|
||||
"found": "找到的空闲时间:{count}"
|
||||
},
|
||||
"share_notifications": {
|
||||
"someone": "有人",
|
||||
"shared": "{name} 与您共享了{kind}“{object}”",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useCalendarStore } from '../calendar-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
|
||||
// Answering for the calendar's owner (#634): the owner's participant gets
|
||||
// the answer and sentBy = mailto:<delegate> in the owner's account, and an
|
||||
// answer without sentBy clears an old one, or Stalwart sends the next reply
|
||||
// with the delegate's SENT-BY still on it.
|
||||
|
||||
function sharedEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
|
||||
return {
|
||||
id: 'alice:e1',
|
||||
originalId: 'e1',
|
||||
uid: 'invite@example.org',
|
||||
title: 'Planning',
|
||||
start: '2026-11-04T14:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Etc/UTC',
|
||||
showWithoutTime: false,
|
||||
calendarIds: { 'alice:b': true },
|
||||
accountId: 'alice',
|
||||
isShared: true,
|
||||
organizerCalendarAddress: 'mailto:carol@example.org',
|
||||
participants: {
|
||||
carol: { '@type': 'Participant', name: 'Carol', email: 'carol@example.org', roles: { owner: true }, participationStatus: 'accepted' },
|
||||
alice: { '@type': 'Participant', name: 'Alice', email: 'alice@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
} as unknown as CalendarEvent['participants'],
|
||||
...overrides,
|
||||
} as CalendarEvent;
|
||||
}
|
||||
|
||||
function fakeClient() {
|
||||
return { updateCalendarEvent: vi.fn().mockResolvedValue(undefined), queryAllCalendarEvents: vi.fn().mockResolvedValue([]) } as unknown as IJMAPClient & {
|
||||
updateCalendarEvent: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useCalendarStore.getState().clearState();
|
||||
});
|
||||
|
||||
describe('rsvpEvent for someone else', () => {
|
||||
it('sets the owner\'s answer and sentBy in the owner\'s account, with scheduling messages', async () => {
|
||||
const client = fakeClient();
|
||||
useCalendarStore.setState({ events: [sharedEvent()] });
|
||||
|
||||
await useCalendarStore.getState().rsvpEvent(client, 'alice:e1', 'alice', 'accepted', undefined, 'series', { sentBy: 'mailto:bob@example.org' });
|
||||
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledWith('e1', {
|
||||
'participants/alice/participationStatus': 'accepted',
|
||||
'participants/alice/sentBy': 'mailto:bob@example.org',
|
||||
}, true, 'alice');
|
||||
const stored = useCalendarStore.getState().events[0].participants!.alice;
|
||||
expect(stored.participationStatus).toBe('accepted');
|
||||
expect(stored.sentBy).toBe('mailto:bob@example.org');
|
||||
});
|
||||
|
||||
it('clears an old sentBy when the participant answers without one', async () => {
|
||||
const client = fakeClient();
|
||||
const event = sharedEvent({ isShared: false, accountId: undefined, id: 'e1' });
|
||||
event.participants!.alice.sentBy = 'mailto:bob@example.org';
|
||||
useCalendarStore.setState({ events: [event] });
|
||||
|
||||
await useCalendarStore.getState().rsvpEvent(client, 'e1', 'alice', 'declined');
|
||||
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledWith('e1', {
|
||||
'participants/alice/participationStatus': 'declined',
|
||||
'participants/alice/sentBy': null,
|
||||
}, true, undefined);
|
||||
expect(useCalendarStore.getState().events[0].participants!.alice.sentBy).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends no sentBy when there is none to set or clear', async () => {
|
||||
const client = fakeClient();
|
||||
useCalendarStore.setState({ events: [sharedEvent()] });
|
||||
|
||||
await useCalendarStore.getState().rsvpEvent(client, 'alice:e1', 'alice', 'tentative');
|
||||
|
||||
expect(client.updateCalendarEvent).toHaveBeenCalledWith('e1', { 'participants/alice/participationStatus': 'tentative' }, true, 'alice');
|
||||
});
|
||||
|
||||
it('puts sentBy on the owner\'s participant when answering one occurrence', async () => {
|
||||
const client = fakeClient();
|
||||
const occurrence = sharedEvent({
|
||||
id: 'alice:maaaab', originalId: 'maaaab', baseEventId: 'e1', recurrenceId: '2026-11-04T14:00:00',
|
||||
recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', count: 3 }] as unknown as CalendarEvent['recurrenceRules'],
|
||||
utcStart: '2026-11-04T14:00:00Z', utcEnd: '2026-11-04T15:00:00Z',
|
||||
});
|
||||
useCalendarStore.setState({ events: [occurrence] });
|
||||
|
||||
await useCalendarStore.getState().rsvpEvent(client, 'alice:maaaab', 'alice', 'accepted', undefined, 'occurrence', { sentBy: 'mailto:bob@example.org' });
|
||||
|
||||
const [, patch] = client.updateCalendarEvent.mock.calls[0];
|
||||
expect(patch.participants.alice).toMatchObject({ participationStatus: 'accepted', sentBy: 'mailto:bob@example.org' });
|
||||
expect(patch.participants.carol).toEqual(occurrence.participants!.carol);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const saved = vi.fn();
|
||||
const loaded = vi.fn();
|
||||
vi.mock('@/lib/vacation-calendar', () => ({
|
||||
vacationReplySaved: (...args: unknown[]) => saved(...args),
|
||||
vacationReplyLoaded: (...args: unknown[]) => loaded(...args),
|
||||
}));
|
||||
|
||||
import { useVacationStore } from '../vacation-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// Every writer of the reply goes through the store (the settings page and
|
||||
// plugins), so the out-of-office calendar block hooks in there.
|
||||
|
||||
const SERVER = {
|
||||
id: 'singleton', isEnabled: true, fromDate: '2026-12-01T08:00:00Z', toDate: '2026-12-05T17:00:00Z',
|
||||
subject: 'Away', textBody: 'Back soon', htmlBody: null,
|
||||
};
|
||||
|
||||
function mockClient() {
|
||||
return {
|
||||
supportsSieve: () => false,
|
||||
accountSupports: () => false,
|
||||
getVacationResponse: vi.fn(async () => ({ ...SERVER })),
|
||||
setVacationResponse: vi.fn(async () => {}),
|
||||
} as unknown as IJMAPClient;
|
||||
}
|
||||
|
||||
const initial = useVacationStore.getState();
|
||||
afterEach(() => {
|
||||
useVacationStore.setState(initial, true);
|
||||
saved.mockClear();
|
||||
loaded.mockClear();
|
||||
});
|
||||
|
||||
describe('vacation store and the calendar block', () => {
|
||||
it('hands the loaded reply over for the account it was loaded for', async () => {
|
||||
const client = mockClient();
|
||||
await useVacationStore.getState().fetchVacationResponse(client, 'boss');
|
||||
expect(loaded).toHaveBeenCalledWith(client, 'boss', {
|
||||
isEnabled: true, fromDate: '2026-12-01T08:00:00Z', toDate: '2026-12-05T17:00:00Z', subject: 'Away',
|
||||
});
|
||||
});
|
||||
|
||||
it('hands over the reply before and after a save', async () => {
|
||||
const client = mockClient();
|
||||
await useVacationStore.getState().fetchVacationResponse(client);
|
||||
await useVacationStore.getState().updateVacationResponse(client, { toDate: '2026-12-08T17:00:00Z' });
|
||||
expect(saved).toHaveBeenCalledWith(
|
||||
client, undefined,
|
||||
expect.objectContaining({ isEnabled: true, toDate: '2026-12-05T17:00:00Z', subject: 'Away' }),
|
||||
{ isEnabled: true, fromDate: '2026-12-01T08:00:00Z', toDate: '2026-12-08T17:00:00Z', subject: 'Away' },
|
||||
);
|
||||
});
|
||||
|
||||
it('hands nothing over when the save failed', async () => {
|
||||
const client = mockClient();
|
||||
(client.setVacationResponse as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('down'));
|
||||
await expect(useVacationStore.getState().updateVacationResponse(client, { isEnabled: false })).rejects.toThrow();
|
||||
expect(saved).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from '@/lib/calendar-subscription-sync';
|
||||
import { registerCalendarSubscriptionSyncBridge } from './settings-store';
|
||||
import { currentStoreEpoch } from '@/lib/store-epoch';
|
||||
import { sentByPatchValue } from '@/lib/calendar-delegate';
|
||||
|
||||
/**
|
||||
* When the Pro shell aggregates calendars/events from every connected
|
||||
@@ -313,6 +314,14 @@ async function destroyOccurrence(
|
||||
await client.updateCalendarEvent(instance.baseEventId, fallback, sendSchedulingMessages, targetAccountId);
|
||||
}
|
||||
|
||||
/** A participant after an answer, with its sentBy set, dropped or kept (see sentByPatchValue). */
|
||||
function withAnswer(participant: CalendarParticipant, status: CalendarParticipant['participationStatus'], sentBy: string | null | undefined): CalendarParticipant {
|
||||
const next: CalendarParticipant = { ...participant, participationStatus: status };
|
||||
if (sentBy) next.sentBy = sentBy;
|
||||
else if (sentBy === null) delete next.sentBy;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer one occurrence of a series as `participantId`. The server stores it
|
||||
* as a recurrence override and sends the organizer an iTIP REPLY carrying
|
||||
@@ -324,8 +333,9 @@ async function rsvpOccurrence(
|
||||
occurrence: CalendarEvent,
|
||||
participantId: string,
|
||||
status: CalendarParticipant['participationStatus'],
|
||||
sentBy?: string | null,
|
||||
): Promise<void> {
|
||||
const patchFor = (options: OverridePatchOptions) => buildOccurrenceRsvpPatch(occurrence, participantId, status, options);
|
||||
const patchFor = (options: OverridePatchOptions) => buildOccurrenceRsvpPatch(occurrence, participantId, status, options, sentBy);
|
||||
const patch = patchFor({ seriesKeys: true });
|
||||
if (!patch) throw new Error('Participant not found on this occurrence');
|
||||
if (target.isOccurrence) {
|
||||
@@ -841,7 +851,13 @@ interface CalendarStore {
|
||||
options?: EventUpdateOptions,
|
||||
) => Promise<boolean>;
|
||||
deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<boolean>;
|
||||
rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null, scope?: 'occurrence' | 'series') => Promise<boolean>;
|
||||
/**
|
||||
* `options.sentBy`: answering for the calendar's owner as their delegate
|
||||
* (`mailto:<user>`, lib/calendar-delegate.ts). Without it an old sentBy
|
||||
* on the participant is cleared, so the owner's own answer isn't sent
|
||||
* as the delegate's.
|
||||
*/
|
||||
rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null, scope?: 'occurrence' | 'series', options?: { sentBy?: string }) => Promise<boolean>;
|
||||
/**
|
||||
* Create `events` in the calendar, linking those whose UID the account
|
||||
* already has; resolves to how many were created or linked, or null when a
|
||||
@@ -1338,7 +1354,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
return true;
|
||||
},
|
||||
|
||||
rsvpEvent: async (client, eventId, participantId, status, replyTo, scope = 'series'): Promise<boolean> => {
|
||||
rsvpEvent: async (client, eventId, participantId, status, replyTo, scope = 'series', options): Promise<boolean> => {
|
||||
set({ error: null });
|
||||
// JMAP participant IDs are opaque strings - they can contain @, ., :,
|
||||
// / etc. The id is RFC 6901-escaped below before being embedded in the
|
||||
@@ -1357,16 +1373,18 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
const { storeEvent, realId, targetAccountId, localAccountId } = target;
|
||||
client = resolveAccountClient(client, localAccountId);
|
||||
const occurrence = target.isOccurrence || target.isBrowserOccurrence ? storeEvent ?? null : null;
|
||||
const sentBy = sentByPatchValue(storeEvent?.participants?.[participantId], options?.sentBy);
|
||||
if (occurrence) {
|
||||
await rsvpOccurrence(
|
||||
client, target, occurrence, participantId,
|
||||
status as CalendarParticipant['participationStatus'],
|
||||
status as CalendarParticipant['participationStatus'], sentBy,
|
||||
);
|
||||
} else {
|
||||
// Escape per RFC 6901 (JSON Pointer): ~ → ~0, / → ~1
|
||||
const escapedId = participantId.replace(/~/g, '~0').replace(/\//g, '~1');
|
||||
const patchKey = `participants/${escapedId}/participationStatus`;
|
||||
const patch: Record<string, unknown> = { [patchKey]: status };
|
||||
if (sentBy !== undefined) patch[`participants/${escapedId}/sentBy`] = sentBy;
|
||||
// Stalwart routes the iTIP REPLY to the stored ORGANIZER
|
||||
// (organizerCalendarAddress); the RFC 8984 replyTo property is retired
|
||||
// in jscalendarbis and ignored. Repair events that are missing the
|
||||
@@ -1389,7 +1407,7 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
...e,
|
||||
participants: {
|
||||
...e.participants,
|
||||
[participantId]: { ...e.participants[participantId], participationStatus: status as CalendarParticipant['participationStatus'] },
|
||||
[participantId]: withAnswer(e.participants[participantId], status as CalendarParticipant['participationStatus'], sentBy),
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -623,6 +623,12 @@ interface SettingsState extends AccessibilitySettings {
|
||||
calendarHideNonWorkingDays: boolean;
|
||||
/** Weekdays as `Date.getDay` numbers (0 = Sunday). */
|
||||
calendarWorkingDays: number[];
|
||||
/** An out-of-office reply with dates puts a busy event in the calendar (lib/vacation-calendar.ts). */
|
||||
vacationBlockCalendar: boolean;
|
||||
/** The times of each account's out-of-office event as Bulwark last wrote them, by account id. */
|
||||
vacationCalendarBlocks: Record<string, { start: string; end: string }>;
|
||||
/** Company calendars (organisation.calendars.feeds) the user hid, by feed id. */
|
||||
hiddenCompanyCalendars: string[];
|
||||
|
||||
// Calendar Tasks
|
||||
enableCalendarTasks: boolean;
|
||||
@@ -946,6 +952,9 @@ const DEFAULT_SETTINGS = {
|
||||
calendarDayEndHour: 20,
|
||||
calendarHideNonWorkingDays: false,
|
||||
calendarWorkingDays: [1, 2, 3, 4, 5] as number[],
|
||||
vacationBlockCalendar: true,
|
||||
vacationCalendarBlocks: {} as Record<string, { start: string; end: string }>,
|
||||
hiddenCompanyCalendars: [] as string[],
|
||||
|
||||
// Calendar Tasks
|
||||
enableCalendarTasks: false,
|
||||
@@ -1386,6 +1395,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
calendarDayEndHour: state.calendarDayEndHour,
|
||||
calendarHideNonWorkingDays: state.calendarHideNonWorkingDays,
|
||||
calendarWorkingDays: state.calendarWorkingDays,
|
||||
vacationBlockCalendar: state.vacationBlockCalendar,
|
||||
vacationCalendarBlocks: state.vacationCalendarBlocks,
|
||||
hiddenCompanyCalendars: state.hiddenCompanyCalendars,
|
||||
toolbarPosition: state.toolbarPosition,
|
||||
hideAccountSwitcher: state.hideAccountSwitcher,
|
||||
showRailAccountList: state.showRailAccountList,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { currentStoreEpoch } from '@/lib/store-epoch';
|
||||
import { debug } from '@/lib/debug';
|
||||
import { vacationHooks } from '@/lib/plugin-hooks';
|
||||
import { vacationToView } from '@/lib/plugin-projection';
|
||||
import { vacationReplyLoaded, vacationReplySaved } from '@/lib/vacation-calendar';
|
||||
|
||||
/** What the vacation card sets for forwarding; the period comes from the vacation. */
|
||||
export type VacationForwardSettings = Pick<VacationForward, 'enabled' | 'to' | 'keepCopy'>;
|
||||
@@ -148,6 +149,9 @@ export const useVacationStore = create<VacationStore>()((set, get) => ({
|
||||
...(filters ? fromFilters(filters) : {}),
|
||||
isLoading: false,
|
||||
});
|
||||
// Out-of-office blocks the calendar (lib/vacation-calendar.ts): the
|
||||
// reply may have been set in another client.
|
||||
vacationReplyLoaded(client, accountId, { isEnabled, fromDate: vacation.fromDate, toDate: vacation.toDate, subject: vacation.subject || '' });
|
||||
} catch (error) {
|
||||
if (stale()) return;
|
||||
set({
|
||||
@@ -232,6 +236,7 @@ export const useVacationStore = create<VacationStore>()((set, get) => ({
|
||||
: { ...current, ...vacationUpdates, ...filtersState, isSaving: false }));
|
||||
// The reply itself is saved even when its filters part failed.
|
||||
void vacationHooks.onVacationUpdate.emit(next);
|
||||
vacationReplySaved(client, accountId, before, { isEnabled: next.isEnabled, fromDate: next.fromDate, toDate: next.toDate, subject: next.subject });
|
||||
if (filtersError) throw filtersError;
|
||||
return true;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user