diff --git a/app/(main)/[locale]/calendar/page.tsx b/app/(main)/[locale]/calendar/page.tsx index edccc34f..07a2e98d 100644 --- a/app/(main)/[locale]/calendar/page.tsx +++ b/app/(main)/[locale]/calendar/page.tsx @@ -16,6 +16,7 @@ import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useAccountStore } from "@/stores/account-store"; +import { usePolicyStore } from "@/stores/policy-store"; import { toast } from "@/stores/toast-store"; import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query"; import { Button } from "@/components/ui/button"; @@ -97,6 +98,7 @@ export default function CalendarPage() { removeCalendar, clearCalendarEvents, refreshAllSubscriptions, icalSubscriptions, } = useCalendarStore(); + const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled')); const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore(); const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors); const setSharedCalendarColor = useSettingsStore((s) => s.setSharedCalendarColor); @@ -179,10 +181,13 @@ export default function CalendarPage() { if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } redirectToLogin(); + } else if (client && !calendarEnabled) { + // Calendar disabled by admin policy - send the user back to mail. + router.push("/"); } else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) { router.push("/"); } - }, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]); + }, [initialCheckDone, isAuthenticated, authLoading, client, calendarEnabled, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]); useEffect(() => { if (error) { @@ -1164,6 +1169,7 @@ export default function CalendarPage() { ) : null; if (!isAuthenticated) return null; + if (!calendarEnabled) return null; if (!supportsCalendar) return renderWebcalAccountPicker(); const renderView = () => { diff --git a/app/(main)/[locale]/settings/page.tsx b/app/(main)/[locale]/settings/page.tsx index 4e3538be..f2137685 100644 --- a/app/(main)/[locale]/settings/page.tsx +++ b/app/(main)/[locale]/settings/page.tsx @@ -627,7 +627,7 @@ export default function SettingsPage() { { id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' }, // Apps - ...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []), + ...(supportsCalendar && isFeatureEnabled('calendarEnabled') ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []), ...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []), ...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []), ...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []), @@ -646,7 +646,7 @@ export default function SettingsPage() { ? ([ managedAccount.capabilities.sieve && supportsSieve ? 'filters' : null, managedAccount.capabilities.mail && supportsVacation ? 'vacation' : null, - managedAccount.capabilities.calendars && supportsCalendar ? 'calendar' : null, + managedAccount.capabilities.calendars && supportsCalendar && isFeatureEnabled('calendarEnabled') ? 'calendar' : null, managedAccount.capabilities.contacts && isFeatureEnabled('contactsEnabled') ? 'contacts' : null, ].filter(Boolean) as Tab[]) : []; diff --git a/app/(main)/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx index 458a2bb1..56eb65e6 100644 --- a/app/(main)/admin/_tabs/policy.tsx +++ b/app/(main)/admin/_tabs/policy.tsx @@ -13,6 +13,7 @@ const FEATURE_GATE_LABELS: Partial s.isFeatureEnabled('sidebarAppsEnabled')); const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled')); + const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled')); const visibleSidebarApps = sidebarAppsEnabled ? sidebarApps : []; const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0; const [isStalwartAdmin, setIsStalwartAdmin] = useState(false); @@ -268,7 +269,7 @@ export function NavigationRail({ const navItems: NavItem[] = [ { id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread }, - { id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar }, + { id: "calendar", icon: Calendar, labelKey: "calendar", href: "/calendar", hidden: !supportsCalendar || !calendarEnabled }, { id: "contacts", icon: BookUser, labelKey: "contacts", href: "/contacts", hidden: !supportsContacts || !contactsEnabled }, { id: "files", icon: HardDrive, labelKey: "files", href: "/files", hidden: !supportsFiles || !filesEnabled }, ]; diff --git a/components/tour/tour-provider.tsx b/components/tour/tour-provider.tsx index e2a94021..1f338228 100644 --- a/components/tour/tour-provider.tsx +++ b/components/tour/tour-provider.tsx @@ -6,6 +6,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { useCalendarStore } from "@/stores/calendar-store"; import { useWebDAVStore } from "@/stores/webdav-store"; import { useSettingsStore } from "@/stores/settings-store"; +import { usePolicyStore } from "@/stores/policy-store"; import { getTourSteps, type TourStep } from "./tour-steps"; import { TourOverlay } from "./tour-overlay"; @@ -38,6 +39,7 @@ export function TourProvider({ children }: { children: ReactNode }) { const pathname = usePathname(); const { isDemoMode } = useAuthStore(); const { supportsCalendar } = useCalendarStore(); + const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled')); const { supportsWebDAV } = useWebDAVStore(); const tourCompleted = useSettingsStore((s) => s.tourCompleted); const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices); @@ -47,7 +49,7 @@ export function TourProvider({ children }: { children: ReactNode }) { const [currentStep, setCurrentStep] = useState(0); const [hasCompletedTour, setHasCompletedTour] = useState(false); - const steps = getTourSteps({ isDemoMode, supportsCalendar, supportsWebDAV: supportsWebDAV !== false }); + const steps = getTourSteps({ isDemoMode, supportsCalendar: supportsCalendar && calendarEnabled, supportsWebDAV: supportsWebDAV !== false }); useEffect(() => { // One-time migration: if the legacy per-device flag is set but the synced diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 1696014d..1a415502 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -51,6 +51,7 @@ export interface FeatureGates { settingsExportEnabled: boolean; customKeywordsEnabled: boolean; templatesEnabled: boolean; + calendarEnabled: boolean; calendarTasksEnabled: boolean; smimeEnabled: boolean; externalContentEnabled: boolean; @@ -75,6 +76,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = { settingsExportEnabled: true, customKeywordsEnabled: true, templatesEnabled: true, + calendarEnabled: true, calendarTasksEnabled: true, smimeEnabled: true, externalContentEnabled: true, diff --git a/lib/telemetry/payload.ts b/lib/telemetry/payload.ts index 5add320c..9d2b1be1 100644 --- a/lib/telemetry/payload.ts +++ b/lib/telemetry/payload.ts @@ -65,7 +65,7 @@ async function readFeatures(): Promise { return { // Booleans only. We read whether a feature is enabled - never any // config value beyond a presence check. - calendar: gates.calendarTasksEnabled === true, + calendar: gates.calendarEnabled === true, contacts: gates.contactsEnabled === true, files: gates.filesEnabled === true, extensions: gates.pluginsEnabled === true,