mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
feat: show company calendars set by the admin
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
// @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[] },
|
||||
}));
|
||||
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' }));
|
||||
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;
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ 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 {
|
||||
@@ -83,8 +84,13 @@ 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 {
|
||||
/**
|
||||
* 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.
|
||||
|
||||
+129
-60
@@ -1,8 +1,12 @@
|
||||
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, feedHostname } from '@/lib/admin/company-feeds';
|
||||
import { companyFeedCache } from '@/lib/admin/company-feed-cache';
|
||||
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 +52,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 } = {},
|
||||
): 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 +92,28 @@ 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 };
|
||||
response = options.allowPrivateHost
|
||||
? 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 +122,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 +136,106 @@ 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(),
|
||||
},
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
const outcome = await fetchIcal(feed.url, {
|
||||
allowPrivateHost: feedHostname(feed) ?? undefined,
|
||||
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 icalResponse(outcome.buffer);
|
||||
}
|
||||
if (cached) {
|
||||
if (outcome.kind === 'not-modified') companyFeedCache.set(feed.url, { ...cached, fetchedAt: Date.now() });
|
||||
return icalResponse(cached.body);
|
||||
}
|
||||
return outcome.kind === 'error'
|
||||
? outcome.response
|
||||
: NextResponse.json({ error: 'Failed to fetch calendar' }, { status: 502 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,184 @@
|
||||
'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 (a private address): only the hosts entered here are
|
||||
let through. They 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), a Lite build without third-party traffic must list the host in its connect-src, and
|
||||
policy.json is a public file there, so don't put tokens in the addresses.{' '}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,9 @@ 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";
|
||||
@@ -1692,10 +1695,36 @@ 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]);
|
||||
|
||||
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).
|
||||
@@ -1719,11 +1748,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(
|
||||
@@ -2000,7 +2030,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}
|
||||
@@ -2147,8 +2177,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'))}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"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[]>>({});
|
||||
const accountKey = 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]);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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),
|
||||
}));
|
||||
|
||||
import {
|
||||
companyCalendarId, createCompanyCalendar, isCompanyCalendarId, loadFeedEvents, resetCompanyCalendarMemory, toCompanyEvents,
|
||||
} from '../company-calendars';
|
||||
import { purgeSignedOutData } from '../sign-out-cleanup';
|
||||
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' }) }));
|
||||
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' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// @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://cal.intranet.example/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.
|
||||
expect(guardedFetch.mock.calls[1][0]).toBe('http://cal.intranet.example/team.ics');
|
||||
expect(guardedFetch.mock.calls[1][2]).toEqual({ allowPrivateHost: 'cal.intranet.example' });
|
||||
});
|
||||
|
||||
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('needs a signed-in user', async () => {
|
||||
creds.value = null;
|
||||
expect((await post({ feedId: 'hol' })).status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// @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 exactly
|
||||
// the host the admin entered resolve to a private address, and nothing else
|
||||
// (a redirect to another private host, another name), with the address 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('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();
|
||||
lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
|
||||
hits = 0;
|
||||
});
|
||||
|
||||
it('reaches the admin\'s intranet host', async () => {
|
||||
const { fetchPublicUrl } = await import('@/lib/security/url-guard');
|
||||
const res = await fetchPublicUrl(`http://cal.intranet.example:${port}/team.ics`, {}, { allowPrivateHost: 'cal.intranet.example' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(hits).toBe(1);
|
||||
});
|
||||
|
||||
it('allows .internal names only for that host', async () => {
|
||||
const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard');
|
||||
const res = await fetchPublicUrl(`http://calendar.corp.internal:${port}/a.ics`, {}, { allowPrivateHost: 'calendar.corp.internal' });
|
||||
expect(res.status).toBe(200);
|
||||
await expect(fetchPublicUrl(`http://other.corp.internal:${port}/a.ics`, {}, { allowPrivateHost: 'calendar.corp.internal' }))
|
||||
.rejects.toBeInstanceOf(DisallowedUrlError);
|
||||
});
|
||||
|
||||
it('keeps refusing every other host that resolves to a private address', async () => {
|
||||
const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard');
|
||||
await expect(fetchPublicUrl(`http://rebind.example:${port}/cal.ics`, {}, { allowPrivateHost: 'cal.intranet.example' }))
|
||||
.rejects.toBeInstanceOf(DisallowedUrlError);
|
||||
await expect(fetchPublicUrl(`http://cal.intranet.example:${port}/team.ics`)).rejects.toBeInstanceOf(DisallowedUrlError);
|
||||
expect(hits).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { allFeeds, feedForSubject, 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('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,38 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
const MAX_ENTRIES = 200;
|
||||
const KEY = Symbol.for('bulwark.companyFeedCache');
|
||||
|
||||
function store(): Map<string, CachedFeed> {
|
||||
const g = globalThis as typeof globalThis & { [KEY]?: Map<string, CachedFeed> };
|
||||
g[KEY] ??= new Map();
|
||||
return g[KEY];
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
clear(): void {
|
||||
store().clear();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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 hostnames of the admin's feeds: the only private hosts a feed fetch may reach. */
|
||||
export function feedHostname(feed: Pick<CompanyFeed, 'url'>): string | null {
|
||||
try {
|
||||
return new URL(feed.url).hostname.toLowerCase();
|
||||
} 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,247 @@
|
||||
/**
|
||||
* 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 { IS_LITE } from '@/lib/lite';
|
||||
import { onFullSignOut } from '@/lib/sign-out-cleanup';
|
||||
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 ──────────────────────────────────────────
|
||||
|
||||
const DB_NAME = 'bulwark-company-calendars';
|
||||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ feedId: 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) {
|
||||
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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,7 +34,7 @@ function isBlockedHostname(hostname: string): boolean {
|
||||
* 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 +46,7 @@ function parseAllowedUrl(urlString: string): URL | null {
|
||||
if (url.username || url.password) return null;
|
||||
|
||||
const hostname = normalizeHostname(url.hostname);
|
||||
if (privateHost && hostname === privateHost) return url;
|
||||
if (isBlockedHostname(hostname)) return null;
|
||||
if (isBlockedIpAddress(hostname)) return null;
|
||||
|
||||
@@ -213,9 +214,50 @@ export function envProxyFor(url: URL): URL | null {
|
||||
}
|
||||
}
|
||||
|
||||
function lookupChecked(hostname: string): Promise<string> {
|
||||
/**
|
||||
* guardedLookup, except that `privateHost` may resolve to a private address:
|
||||
* an intranet host the admin entered themselves (company calendars). Every
|
||||
* other name, redirect targets included, is still checked, 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;
|
||||
}
|
||||
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 +271,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 +284,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 +308,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 +339,30 @@ export type PublicFetchResponse = UndiciResponse;
|
||||
export async function fetchPublicUrl(
|
||||
url: string,
|
||||
init: Omit<UndiciRequestInit, 'dispatcher' | 'redirect'> = {},
|
||||
options: {
|
||||
/**
|
||||
* One hostname that may be a private (intranet) host: only for an
|
||||
* address the admin entered (company calendars). It must equal the
|
||||
* URL's hostname to make any difference.
|
||||
*/
|
||||
allowPrivateHost?: string;
|
||||
} = {},
|
||||
): Promise<PublicFetchResponse> {
|
||||
const parsed = parseAllowedUrl(url);
|
||||
const privateHost = 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);
|
||||
|
||||
@@ -4845,6 +4845,9 @@
|
||||
"cancelled": "ألغى {name} \"{title}\"",
|
||||
"untitled": "حدث بدون عنوان"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "المؤسسة"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "حجز تقويمي",
|
||||
"description": "يضيف حدثًا مشغولًا لمدة غيابك، حتى لا يحجز أحد اجتماعات معك خلالها.",
|
||||
|
||||
@@ -4845,6 +4845,9 @@
|
||||
"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.",
|
||||
|
||||
@@ -5283,6 +5283,9 @@
|
||||
"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.",
|
||||
|
||||
@@ -4845,6 +4845,9 @@
|
||||
"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.",
|
||||
|
||||
@@ -5283,6 +5283,9 @@
|
||||
"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.",
|
||||
|
||||
@@ -4845,6 +4845,9 @@
|
||||
"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.",
|
||||
|
||||
@@ -5283,6 +5283,9 @@
|
||||
"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.",
|
||||
|
||||
@@ -4845,6 +4845,9 @@
|
||||
"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.",
|
||||
|
||||
@@ -4845,6 +4845,9 @@
|
||||
"cancelled": "{name} «{title}» را لغو کرد",
|
||||
"untitled": "رویداد بدون عنوان"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "سازمان"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "مسدود کردن تقویم من",
|
||||
"description": "برای مدت غیبت شما یک رویداد مشغول اضافه میکند تا کسی در آن زمان جلسه نگذارد.",
|
||||
|
||||
@@ -5283,6 +5283,9 @@
|
||||
"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à.",
|
||||
|
||||
@@ -5255,6 +5255,9 @@
|
||||
"cancelled": "{name} ביטל/ה את \"{title}\"",
|
||||
"untitled": "אירוע ללא כותרת"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "ארגון"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "חסום את היומן שלי",
|
||||
"description": "מוסיף אירוע תפוס לזמן ההיעדרות, כדי שאף אחד לא יקבע איתך פגישות אז.",
|
||||
|
||||
@@ -4845,6 +4845,9 @@
|
||||
"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.",
|
||||
|
||||
@@ -5283,6 +5283,9 @@
|
||||
"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.",
|
||||
|
||||
@@ -5283,6 +5283,9 @@
|
||||
"cancelled": "{name} さんが「{title}」をキャンセルしました",
|
||||
"untitled": "無題の予定"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "組織"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "カレンダーをブロック",
|
||||
"description": "不在の期間に予定ありの予定を追加し、その間に会議を入れられないようにします。",
|
||||
|
||||
@@ -5283,6 +5283,9 @@
|
||||
"cancelled": "{name}님이 \"{title}\"을(를) 취소했습니다",
|
||||
"untitled": "제목 없는 일정"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "조직"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "내 캘린더 차단",
|
||||
"description": "부재 기간에 바쁨 일정을 추가해 그동안 회의가 잡히지 않게 합니다.",
|
||||
|
||||
@@ -5283,6 +5283,9 @@
|
||||
"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.",
|
||||
|
||||
@@ -4845,6 +4845,9 @@
|
||||
"cancelled": "{name} \"{title}\"-г цуцаллаа",
|
||||
"untitled": "Нэргүй арга хэмжээ"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Байгууллага"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Миний хуанлийг хаах",
|
||||
"description": "Таныг байхгүй үед завгүй арга хэмжээ нэмж, тэр үед хэн ч уулзалт товлохгүй болгоно.",
|
||||
|
||||
@@ -4845,6 +4845,9 @@
|
||||
"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.",
|
||||
|
||||
@@ -5283,6 +5283,9 @@
|
||||
"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.",
|
||||
|
||||
@@ -5283,6 +5283,9 @@
|
||||
"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ń.",
|
||||
|
||||
@@ -5283,6 +5283,9 @@
|
||||
"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.",
|
||||
|
||||
@@ -4845,6 +4845,9 @@
|
||||
"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.",
|
||||
|
||||
@@ -5283,6 +5283,9 @@
|
||||
"cancelled": "{name} отменил(а) «{title}»",
|
||||
"untitled": "Событие без названия"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Организация"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Занять мой календарь",
|
||||
"description": "Добавляет событие «занят» на время отсутствия, чтобы вам не назначали встречи.",
|
||||
|
||||
@@ -4845,6 +4845,9 @@
|
||||
"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.",
|
||||
|
||||
@@ -2185,6 +2185,9 @@
|
||||
"cancelled": "{name} ยกเลิกกิจกรรม “{title}”",
|
||||
"untitled": "กิจกรรมที่ไม่มีชื่อ"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "องค์กร"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "บล็อกปฏิทินของฉัน",
|
||||
"description": "เพิ่มกิจกรรมไม่ว่างในช่วงที่คุณไม่อยู่ เพื่อไม่ให้ใครนัดประชุมกับคุณในเวลานั้น",
|
||||
|
||||
@@ -4845,6 +4845,9 @@
|
||||
"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.",
|
||||
|
||||
@@ -5283,6 +5283,9 @@
|
||||
"cancelled": "{name} скасував(ла) «{title}»",
|
||||
"untitled": "Подія без назви"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "Організація"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "Заблокувати мій календар",
|
||||
"description": "Додає подію «зайнято» на час вашої відсутності, щоб вам тоді не призначали зустрічей.",
|
||||
|
||||
@@ -4857,6 +4857,9 @@
|
||||
"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 đó.",
|
||||
|
||||
@@ -4845,6 +4845,9 @@
|
||||
"cancelled": "{name} 取消了「{title}」",
|
||||
"untitled": "未命名活動"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "組織"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "佔用我的行事曆",
|
||||
"description": "在你外出期間新增一個忙碌活動,這樣別人不會在這段時間約你開會。",
|
||||
|
||||
@@ -5283,6 +5283,9 @@
|
||||
"cancelled": "{name} 取消了“{title}”",
|
||||
"untitled": "无标题活动"
|
||||
},
|
||||
"company_calendars": {
|
||||
"section": "组织"
|
||||
},
|
||||
"vacation_calendar": {
|
||||
"label": "占用我的日历",
|
||||
"description": "在你外出期间添加一个忙碌活动,这样别人不会在这段时间约你开会。",
|
||||
|
||||
@@ -627,6 +627,8 @@ interface SettingsState extends AccessibilitySettings {
|
||||
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;
|
||||
@@ -952,6 +954,7 @@ const DEFAULT_SETTINGS = {
|
||||
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,
|
||||
@@ -1394,6 +1397,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
calendarWorkingDays: state.calendarWorkingDays,
|
||||
vacationBlockCalendar: state.vacationBlockCalendar,
|
||||
vacationCalendarBlocks: state.vacationCalendarBlocks,
|
||||
hiddenCompanyCalendars: state.hiddenCompanyCalendars,
|
||||
toolbarPosition: state.toolbarPosition,
|
||||
hideAccountSwitcher: state.hideAccountSwitcher,
|
||||
showRailAccountList: state.showRailAccountList,
|
||||
|
||||
Reference in New Issue
Block a user