calendar and contacts are a plugin now — plugins/caldav, its own repository like every other one. a fresh machine runs five processes instead of seven and gets a calendar by installing one plugin, which is the whole point: the platform stops carrying a python calendar server for people who may not want one. what left: the radicale sidecar, four routers, the panel apps, the dav_app_passwords table, the two screens, the two dock tiles, the permission registry entry, the settings section, and radicale from every package list. three platform things had to change first, each committed on its own:6b5c87daa plugin can be more than one screen;e0df28ffa first-party plugin can claim a reserved root path. this commit adds the third, `personal` on a plugin permission — a member granted READ must still mint and revoke their own app password, because that password is how their own phone reaches their own calendar. those rows are theirs and nobody else's. two fixes the work surfaced rather than caused. the dock pinned mountPrefix, so a multi-screen plugin pinned a tile pointing at /caldav — a route no plugin serves. and the OPTIONS carve-out was a literal list of four DAV paths; it asks the claim registry now, so it follows whoever owns the route. /calendar stays in DEFAULT_DOCK_PATHS. it is installed by default and a fresh dock that silently dropped it would read as a downgrade. docs:check earned its keep twice here: it caught spotlight still offering /settings/integrations/dav-app-passwords after the section was deleted, and the permission count going 22 -> 21. neither is greppable and both were already wrong when it ran. verified against alpha, which is still on the pre-extraction code: /.well-known/ caldav 301s, /dav/ answers 401 with the same WWW-Authenticate realm byte for byte, /api/caldav/passwords 401s, and OPTIONS reaches the DAV router rather than being answered as a CORS preflight. installed here end to end — schema applied, sidecar started, two tiles with their own artwork behind one permission. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
134 lines
5.2 KiB
TypeScript
134 lines
5.2 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { useNavigate, useParams } from 'react-router';
|
|
import { Puzzle, KeyRound, Globe, Wrench, CalendarDays } from 'lucide-react';
|
|
import type { LayoutNode, PanelComponents } from 'officerdev';
|
|
import { WorkspaceLayout } from 'officerdev';
|
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
|
|
import { SettingsRoute, SettingsSidebar, SettingsContent, type SettingsSection } from '../SettingsPanel';
|
|
import { GoogleOAuthConfig } from './GoogleOAuthConfig';
|
|
// import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now
|
|
import { ApifyConfig } from './ApifyConfig';
|
|
import { useTranslation } from '@/lib/i18n';
|
|
|
|
const BASE_PATH = '/settings/integrations';
|
|
|
|
const enterpriseSections: SettingsSection[] = [
|
|
{
|
|
key: 'google-oauth',
|
|
icon: KeyRound,
|
|
titleKey: 'settings.sections.googleOauth.label',
|
|
descriptionKey: 'settings.sections.googleOauth.description',
|
|
content: <GoogleOAuthConfig />,
|
|
},
|
|
{
|
|
key: 'apify',
|
|
icon: Wrench,
|
|
titleKey: 'settings.sections.apify.label',
|
|
descriptionKey: 'settings.sections.apify.description',
|
|
content: <ApifyConfig />,
|
|
},
|
|
];
|
|
|
|
const personalSections: SettingsSection[] = [
|
|
// Email account management left with the feature on 2026-08-18 (`plugins/email/`). A plugin contributes
|
|
// panels, not settings sections, so it now lives inside the email workspace itself.
|
|
// Google OAuth is left in place (code + DB) but hidden — email now uses an app password over IMAP,
|
|
// so there's no reason to reconnect Google here. Re-enable this section to bring it back.
|
|
// {
|
|
// key: 'google-account',
|
|
// icon: UserCircle,
|
|
// title: 'Google Account',
|
|
// descriptionKey: 'settings.sections.apify.description',
|
|
// content: <GoogleAccount />,
|
|
// },
|
|
// DAV app passwords left with the caldav plugin on 2026-08-27. A plugin cannot contribute a settings
|
|
// section — it ships panels — so the form is a modal on the Calendar screen, the same answer the email
|
|
// plugin reached for account management when it was extracted.
|
|
];
|
|
|
|
// The Enterprise/Personal tab is not a second thing to remember — it is a fact about which section is
|
|
// open, so it is derived from the URL rather than kept in its own global. That also fixes the deep link:
|
|
// with the tab held separately, `/settings/integrations/apify` would have arrived with the tab
|
|
// still on Enterprise and the content pane showing "Select a section".
|
|
const allSections = [...enterpriseSections, ...personalSections];
|
|
const tabForSection = (section: string | undefined) =>
|
|
personalSections.some((s) => s.key === section) ? 'personal' : 'enterprise';
|
|
|
|
const IntegrationsSidebar = () => {
|
|
const { t } = useTranslation();
|
|
const { section } = useParams<{ section?: string }>();
|
|
const navigate = useNavigate();
|
|
const tab = tabForSection(section);
|
|
const sections = tab === 'enterprise' ? enterpriseSections : personalSections;
|
|
|
|
// Switching tab is a navigation, so it lands on that tab's first section rather than leaving the
|
|
// content pane showing a section the sidebar no longer lists.
|
|
const setTab = (next: string) => {
|
|
const first = (next === 'enterprise' ? enterpriseSections : personalSections)[0];
|
|
if (first) navigate(`${BASE_PATH}/${first.key}`);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<div className="p-3 pb-2">
|
|
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
|
|
<Puzzle className="h-4 w-4" />
|
|
{t('settings.nav.integrations')}
|
|
</div>
|
|
</div>
|
|
<div className="px-3 pb-2">
|
|
<Tabs value={tab} onValueChange={setTab}>
|
|
<TabsList className="w-full">
|
|
<TabsTrigger value="enterprise" className="flex-1 cursor-pointer">
|
|
{t('settings.integrations.enterprise')}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="personal" className="flex-1 cursor-pointer">
|
|
{t('settings.integrations.personal')}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
</div>
|
|
<SettingsSidebar
|
|
basePath={BASE_PATH}
|
|
icon={Puzzle}
|
|
labelKey="settings.nav.integrations"
|
|
sections={sections}
|
|
hideHeader
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Both tabs' sections, because the URL alone says which one is open and the pane no longer has to agree
|
|
// with the sidebar about the tab to find it.
|
|
const IntegrationsContent = () => <SettingsContent sections={allSections} />;
|
|
|
|
const layout: LayoutNode = {
|
|
type: 'group',
|
|
id: 'integrations-root',
|
|
direction: 'horizontal',
|
|
children: [
|
|
{ node: { type: 'panel', id: 'integrations-left', appType: null }, size: 20 },
|
|
{ node: { type: 'panel', id: 'integrations-right', appType: null }, size: 80 },
|
|
],
|
|
};
|
|
|
|
export const IntegrationsSettings = () => {
|
|
const panelComponents: PanelComponents = useMemo(
|
|
() => ({
|
|
'integrations-left': IntegrationsSidebar,
|
|
'integrations-right': IntegrationsContent,
|
|
}),
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<SettingsRoute basePath={BASE_PATH} sections={allSections}>
|
|
<div className="h-full w-full pt-2">
|
|
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
|
|
</div>
|
|
</SettingsRoute>
|
|
);
|
|
};
|