diff --git a/app/api/admin/__tests__/policy-feed-urls-route.test.ts b/app/api/admin/__tests__/policy-feed-urls-route.test.ts new file mode 100644 index 000000000..09716346c --- /dev/null +++ b/app/api/admin/__tests__/policy-feed-urls-route.test.ts @@ -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); + }); +}); diff --git a/app/api/admin/policy/route.ts b/app/api/admin/policy/route.ts index 11c972e5e..9f796f9ad 100644 --- a/app/api/admin/policy/route.ts +++ b/app/api/admin/policy/route.ts @@ -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. diff --git a/app/api/fetch-ical/route.ts b/app/api/fetch-ical/route.ts index c90b2cec8..3058e129a 100644 --- a/app/api/fetch-ical/route.ts +++ b/app/api/fetch-ical/route.ts @@ -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 { + 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 { + 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 }); +} diff --git a/components/admin/org/__tests__/company-calendars-section.test.tsx b/components/admin/org/__tests__/company-calendars-section.test.tsx new file mode 100644 index 000000000..203937a13 --- /dev/null +++ b/components/admin/org/__tests__/company-calendars-section.test.tsx @@ -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 = {}; +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 | null = null; +function Harness() { + const draft = useOrgPolicyDraft('test'); + latest = draft; + if (draft.loading) return null; + return ; +} + +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(); + 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(); + 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(); + 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'); + }); +}); diff --git a/components/admin/org/sections/company-calendars-section.tsx b/components/admin/org/sections/company-calendars-section.tsx index 82602702a..291c5452d 100644 --- a/components/admin/org/sections/company-calendars-section.tsx +++ b/components/admin/org/sections/company-calendars-section.tsx @@ -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) => onChange({ ...feed, ...patch }); + const urlProblem = feedUrlProblem(feed.url); + return ( +
  • +
    +
    + + set({ name: e.target.value })} /> +
    +
    + + set({ color: e.target.value })} /> +
    +
    +
    + + set({ url: e.target.value })} + /> + {urlProblem &&

    {urlProblem} A feed without one is not saved.

    } +
    +
    +
    + + { + 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)) }); + }} + /> +
    + +
    + {!feed.name.trim() &&

    A calendar without a name is not saved. Give it one.

    } +
  • + ); +} + /** - * 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 {null}; + 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 ( + +
    + {inScope && ( +
    +

    From Everyone

    + {everyone.length === 0 ? ( +

    Everyone has no company calendars.

    + ) : ( +
      + {everyone.map((feed) => ( +
    • +
    • + ))} +
    + )} +

    For this scope

    +

    Users in this scope get Everyone's calendars and these.

    +
    + )} + + {own.length === 0 ? ( +

    + {inScope ? 'Nothing added for this scope.' : 'No company calendars yet.'} +

    + ) : ( +
      + {own.map((feed, index) => ( + setOwn(own.map((f, i) => (i === index ? next : f)))} + onRemove={() => setOwn(own.filter((_, i) => i !== index))} + /> + ))} +
    + )} + +
    + + {total >= MAX_FEEDS && At most {MAX_FEEDS} company calendars.} +
    + +
    +

    + 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. +

    +

    + 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. +

    +

    + 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.{' '} + + How to set it up + +

    +
    +
    +
    + ); } diff --git a/components/calendar/__tests__/calendar-sidebar-company.test.tsx b/components/calendar/__tests__/calendar-sidebar-company.test.tsx new file mode 100644 index 000000000..e1d2e9170 --- /dev/null +++ b/components/calendar/__tests__/calendar-sidebar-company.test.tsx @@ -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( + '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(); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index a5759c0b3..f69e87802 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -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 = { if (calendarId === BIRTHDAY_CALENDAR_ID) { updateSetting('birthdayCalendarColor', color); diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 128929162..8b14360de 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -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 (
    @@ -607,6 +611,12 @@ export function CalendarSidebarPanel({ onCreateCalendar, )} + {companyCalendars.length > 0 && ( +
    + {renderSection('__organisation__', tCompany('section'), companyCalendars)} +
    + )} + {sharedAccountGroups.length > 0 && (
    {renderSectionHeader('__others__', t('other_calendars'))} diff --git a/components/calendar/use-company-calendars.ts b/components/calendar/use-company-calendars.ts new file mode 100644 index 000000000..c1d29ca0b --- /dev/null +++ b/components/calendar/use-company-calendars.ts @@ -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>({}); + 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]); +} diff --git a/lib/__tests__/company-calendars.test.ts b/lib/__tests__/company-calendars.test.ts new file mode 100644 index 000000000..5dae25705 --- /dev/null +++ b/lib/__tests__/company-calendars.test.ts @@ -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()), + get IS_LITE() { return lite.value; }, +})); +const apiFetch = vi.fn(); +vi.mock('@/lib/browser-navigation', async (original) => ({ + ...(await original()), + 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[]), + }; +} + +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' }); + }); +}); diff --git a/lib/__tests__/fetch-ical-feed-route.test.ts b/lib/__tests__/fetch-ical-feed-route.test.ts new file mode 100644 index 000000000..3de311eca --- /dev/null +++ b/lib/__tests__/fetch-ical-feed-route.test.ts @@ -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 }) { + 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 })); +vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: async () => creds.value })); + +const guardedFetch = vi.fn(); +vi.mock('@/lib/security/url-guard', async (importOriginal) => ({ + ...(await importOriginal()), + 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, + types: {} as Record, +})); +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 = {}) => 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); + }); +}); diff --git a/lib/__tests__/url-guard-private-host.test.ts b/lib/__tests__/url-guard-private-host.test.ts new file mode 100644 index 000000000..2ba620485 --- /dev/null +++ b/lib/__tests__/url-guard-private-host.test.ts @@ -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(); + 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((resolve) => server.listen(0, '127.0.0.1', resolve)); + port = (server.address() as AddressInfo).port; + }); + afterAll(async () => { + await new Promise((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); + }); +}); diff --git a/lib/admin/__tests__/company-feeds.test.ts b/lib/admin/__tests__/company-feeds.test.ts new file mode 100644 index 000000000..77a579132 --- /dev/null +++ b/lib/admin/__tests__/company-feeds.test.ts @@ -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'], + ]); + }); +}); diff --git a/lib/admin/__tests__/organisation-policy-enterprise.test.ts b/lib/admin/__tests__/organisation-policy-enterprise.test.ts index 76d0cc746..91a5395ef 100644 --- a/lib/admin/__tests__/organisation-policy-enterprise.test.ts +++ b/lib/admin/__tests__/organisation-policy-enterprise.test.ts @@ -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); diff --git a/lib/admin/company-feed-cache.ts b/lib/admin/company-feed-cache.ts new file mode 100644 index 000000000..12ffee41c --- /dev/null +++ b/lib/admin/company-feed-cache.ts @@ -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 { + const g = globalThis as typeof globalThis & { [KEY]?: Map }; + 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(); + }, +}; diff --git a/lib/admin/company-feeds.ts b/lib/admin/company-feeds.ts new file mode 100644 index 000000000..306f17076 --- /dev/null +++ b/lib/admin/company-feeds.ts @@ -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; + const calendars = org.calendars as Record | 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), 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): 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): string | null { + try { + return new URL(feed.url).hostname.toLowerCase(); + } catch { + return null; + } +} diff --git a/lib/admin/organisation-policy.ts b/lib/admin/organisation-policy.ts index c6c22e93f..ad9a9f5a9 100644 --- a/lib/admin/organisation-policy.ts +++ b/lib/admin/organisation-policy.ts @@ -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 } : {}) }; }); } diff --git a/lib/admin/reachable-hosts.ts b/lib/admin/reachable-hosts.ts index cfae6020d..5782d6211 100644 --- a/lib/admin/reachable-hosts.ts +++ b/lib/admin/reachable-hosts.ts @@ -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 & { features?: Partial }; + policy: Pick & Partial> & { features?: Partial }; env: Record; /** 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'); diff --git a/lib/admin/server-policy-subject.ts b/lib/admin/server-policy-subject.ts new file mode 100644 index 000000000..4d529f1cf --- /dev/null +++ b/lib/admin/server-policy-subject.ts @@ -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; + +interface SessionAccount { + name?: string; + isPersonal?: boolean; +} + +// One cache per process, not per bundle copy. +const CACHE_KEY = Symbol.for('bulwark.serverPolicySubjects'); +function cache(): Map }> { + const g = globalThis as typeof globalThis & { [CACHE_KEY]?: Map }> }; + g[CACHE_KEY] ??= new Map(); + return g[CACHE_KEY]; +} + +async function resolve(creds: Creds): Promise { + 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) + .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 | 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 { + 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(); +} diff --git a/lib/company-calendars.ts b/lib/company-calendars.ts new file mode 100644 index 000000000..aec6a9b5f --- /dev/null +++ b/lib/company-calendars.ts @@ -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): 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[] { + const calendarId = companyCalendarId(feedId); + const events: CalendarEvent[] = []; + parsed.forEach((event, index) => { + if (typeof event.start !== 'string' || !event.start) return; + const rest = { ...(event as Record) }; + delete rest.method; + events.push({ + ...(rest as Partial), + 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[]; +} + +// ── IndexedDB, per account ────────────────────────────────────────── + +const DB_NAME = 'bulwark-company-calendars'; +const STORE = 'feeds'; + +function openDb(): Promise { + 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 { + 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; +} + +async function idbPut(key: string, value: FeedCacheEntry): Promise { + const db = await openDb(); + if (!db) return; + await new Promise((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(); + +/** 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 { + 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 { + 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; + +const inFlight = new Map[]>>(); + +/** + * 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[]> { + 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(); +} diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 107af8909..7d04c19e8 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -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; } /** diff --git a/lib/security/url-guard.ts b/lib/security/url-guard.ts index b022131a3..2a60ed429 100644 --- a/lib/security/url-guard.ts +++ b/lib/security/url-guard.ts @@ -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 { +/** + * 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(); + +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 { + 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(); * 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 = {}, + 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 { - 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); diff --git a/locales/ar/common.json b/locales/ar/common.json index 86fc84cbe..f8d53bf95 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -4845,6 +4845,9 @@ "cancelled": "ألغى {name} \"{title}\"", "untitled": "حدث بدون عنوان" }, + "company_calendars": { + "section": "المؤسسة" + }, "vacation_calendar": { "label": "حجز تقويمي", "description": "يضيف حدثًا مشغولًا لمدة غيابك، حتى لا يحجز أحد اجتماعات معك خلالها.", diff --git a/locales/ca/common.json b/locales/ca/common.json index 7304563ef..de663a0b4 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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.", diff --git a/locales/cs/common.json b/locales/cs/common.json index f06b2b2a0..2c4daa2a8 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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.", diff --git a/locales/da/common.json b/locales/da/common.json index 20ac3e1e2..3ecd63e8c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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.", diff --git a/locales/de/common.json b/locales/de/common.json index 423336c2f..6adb2ce00 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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.", diff --git a/locales/en/common.json b/locales/en/common.json index 8f07b6ce6..4150db223 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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.", diff --git a/locales/es/common.json b/locales/es/common.json index 142117f28..019e0d458 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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.", diff --git a/locales/et/common.json b/locales/et/common.json index eafff1c55..0a08e500b 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -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.", diff --git a/locales/fa/common.json b/locales/fa/common.json index c603ee3bc..a432fab35 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -4845,6 +4845,9 @@ "cancelled": "{name} «{title}» را لغو کرد", "untitled": "رویداد بدون عنوان" }, + "company_calendars": { + "section": "سازمان" + }, "vacation_calendar": { "label": "مسدود کردن تقویم من", "description": "برای مدت غیبت شما یک رویداد مشغول اضافه می‌کند تا کسی در آن زمان جلسه نگذارد.", diff --git a/locales/fr/common.json b/locales/fr/common.json index 39049d7e9..1f988f50e 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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à.", diff --git a/locales/he/common.json b/locales/he/common.json index d87ddf5d8..64a3f63af 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5255,6 +5255,9 @@ "cancelled": "{name} ביטל/ה את \"{title}\"", "untitled": "אירוע ללא כותרת" }, + "company_calendars": { + "section": "ארגון" + }, "vacation_calendar": { "label": "חסום את היומן שלי", "description": "מוסיף אירוע תפוס לזמן ההיעדרות, כדי שאף אחד לא יקבע איתך פגישות אז.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 14fbe92e8..76d7acaaf 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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.", diff --git a/locales/it/common.json b/locales/it/common.json index 4d084d482..1b7ab1a9a 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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.", diff --git a/locales/ja/common.json b/locales/ja/common.json index acabf69c8..d0eb78511 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5283,6 +5283,9 @@ "cancelled": "{name} さんが「{title}」をキャンセルしました", "untitled": "無題の予定" }, + "company_calendars": { + "section": "組織" + }, "vacation_calendar": { "label": "カレンダーをブロック", "description": "不在の期間に予定ありの予定を追加し、その間に会議を入れられないようにします。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 717862f6c..c04cfa17b 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5283,6 +5283,9 @@ "cancelled": "{name}님이 \"{title}\"을(를) 취소했습니다", "untitled": "제목 없는 일정" }, + "company_calendars": { + "section": "조직" + }, "vacation_calendar": { "label": "내 캘린더 차단", "description": "부재 기간에 바쁨 일정을 추가해 그동안 회의가 잡히지 않게 합니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index 6de04d08f..7fdddf32b 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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.", diff --git a/locales/mn/common.json b/locales/mn/common.json index 2b3141027..f91e264bb 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -4845,6 +4845,9 @@ "cancelled": "{name} \"{title}\"-г цуцаллаа", "untitled": "Нэргүй арга хэмжээ" }, + "company_calendars": { + "section": "Байгууллага" + }, "vacation_calendar": { "label": "Миний хуанлийг хаах", "description": "Таныг байхгүй үед завгүй арга хэмжээ нэмж, тэр үед хэн ч уулзалт товлохгүй болгоно.", diff --git a/locales/nb/common.json b/locales/nb/common.json index 0b47650f7..fcfa39195 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 8ea39c2c6..bc0031dc7 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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.", diff --git a/locales/pl/common.json b/locales/pl/common.json index 84b79e5ec..bf49f5381 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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ń.", diff --git a/locales/pt/common.json b/locales/pt/common.json index 981966bc6..bb5a6148b 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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.", diff --git a/locales/ro/common.json b/locales/ro/common.json index 1462cb8f3..0944e7d7e 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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.", diff --git a/locales/ru/common.json b/locales/ru/common.json index 101df2134..d8d62cd85 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5283,6 +5283,9 @@ "cancelled": "{name} отменил(а) «{title}»", "untitled": "Событие без названия" }, + "company_calendars": { + "section": "Организация" + }, "vacation_calendar": { "label": "Занять мой календарь", "description": "Добавляет событие «занят» на время отсутствия, чтобы вам не назначали встречи.", diff --git a/locales/sk/common.json b/locales/sk/common.json index 8ac9073dc..6254c2866 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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.", diff --git a/locales/th/common.json b/locales/th/common.json index 6d115429a..646993b79 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2185,6 +2185,9 @@ "cancelled": "{name} ยกเลิกกิจกรรม “{title}”", "untitled": "กิจกรรมที่ไม่มีชื่อ" }, + "company_calendars": { + "section": "องค์กร" + }, "vacation_calendar": { "label": "บล็อกปฏิทินของฉัน", "description": "เพิ่มกิจกรรมไม่ว่างในช่วงที่คุณไม่อยู่ เพื่อไม่ให้ใครนัดประชุมกับคุณในเวลานั้น", diff --git a/locales/tr/common.json b/locales/tr/common.json index 21aa0af9c..a207917a2 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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.", diff --git a/locales/uk/common.json b/locales/uk/common.json index 24b359a8a..dc05fba27 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5283,6 +5283,9 @@ "cancelled": "{name} скасував(ла) «{title}»", "untitled": "Подія без назви" }, + "company_calendars": { + "section": "Організація" + }, "vacation_calendar": { "label": "Заблокувати мій календар", "description": "Додає подію «зайнято» на час вашої відсутності, щоб вам тоді не призначали зустрічей.", diff --git a/locales/vi/common.json b/locales/vi/common.json index 4445f0894..42f959090 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -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 đó.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 801825c00..dbd483fe0 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -4845,6 +4845,9 @@ "cancelled": "{name} 取消了「{title}」", "untitled": "未命名活動" }, + "company_calendars": { + "section": "組織" + }, "vacation_calendar": { "label": "佔用我的行事曆", "description": "在你外出期間新增一個忙碌活動,這樣別人不會在這段時間約你開會。", diff --git a/locales/zh/common.json b/locales/zh/common.json index 61364c418..fbbc0e5f5 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5283,6 +5283,9 @@ "cancelled": "{name} 取消了“{title}”", "untitled": "无标题活动" }, + "company_calendars": { + "section": "组织" + }, "vacation_calendar": { "label": "占用我的日历", "description": "在你外出期间添加一个忙碌活动,这样别人不会在这段时间约你开会。", diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 537bba3ed..d47af7bf8 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -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; + /** 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, + hiddenCompanyCalendars: [] as string[], // Calendar Tasks enableCalendarTasks: false, @@ -1394,6 +1397,7 @@ export const useSettingsStore = create()( calendarWorkingDays: state.calendarWorkingDays, vacationBlockCalendar: state.vacationBlockCalendar, vacationCalendarBlocks: state.vacationCalendarBlocks, + hiddenCompanyCalendars: state.hiddenCompanyCalendars, toolbarPosition: state.toolbarPosition, hideAccountSwitcher: state.hideAccountSwitcher, showRailAccountList: state.showRailAccountList,