caldav leaves the platform
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
This commit is contained in:
-226
@@ -1,226 +0,0 @@
|
||||
import type { FormEvent } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Copy, Trash2, Ban, Plus, Smartphone } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
import { useFormat } from 'i18n';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
// Per-device credentials for calendar and contacts sync (DAVx5, iOS, macOS, Thunderbird).
|
||||
//
|
||||
// The generated password is returned by POST and never again — it is stored as an argon2 hash, so there
|
||||
// is nothing to read back. That single fact drives the whole design of this screen: the new credential
|
||||
// is shown in a panel that stays put until dismissed, with the server URL and username beside it,
|
||||
// because the moment it disappears the only remedy is to revoke and mint another.
|
||||
|
||||
type DavPassword = {
|
||||
id: number;
|
||||
label: string;
|
||||
hint: string;
|
||||
lastUsedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type Minted = { password: string; username: string; label: string };
|
||||
|
||||
// Dates come from useFormat() below, bound to the app's locale rather than the browser's (D7).
|
||||
|
||||
export const DavAppPasswords = () => {
|
||||
const { t } = useTranslation();
|
||||
const { date: formatDate } = useFormat();
|
||||
|
||||
const copy = async (text: string, what: string) => {
|
||||
try {
|
||||
await copyToClipboard(text);
|
||||
toast.success(t('settings.dav.copied', { what }));
|
||||
} catch {
|
||||
toast.error(t('settings.dav.copyFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const client = useClient();
|
||||
const [passwords, setPasswords] = useState<DavPassword[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [label, setLabel] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [minted, setMinted] = useState<Minted | null>(null);
|
||||
|
||||
// The base the phone should be pointed at. Taken from the browser rather than configured: it is by
|
||||
// definition the address that reached this page, which is the one that will work.
|
||||
const serverUrl = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
|
||||
const load = useCallback(() => {
|
||||
client
|
||||
.get<{ passwords: DavPassword[] }>('/dav/passwords')
|
||||
.then((data) => setPasswords(data?.passwords ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [client]);
|
||||
|
||||
useEffect(load, [load]);
|
||||
|
||||
const create = async (ev?: FormEvent) => {
|
||||
ev?.preventDefault();
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed || isCreating) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const res = await client.post<{ password: string; username: string }>('/dav/passwords', { label: trimmed });
|
||||
setMinted({ password: res.password, username: res.username, label: trimmed });
|
||||
setLabel('');
|
||||
load();
|
||||
} catch {
|
||||
toast.error(t('settings.dav.createFailed'));
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async (id: number) => {
|
||||
try {
|
||||
await client.post(`/dav/passwords/${id}/revoke`, {});
|
||||
toast.success(t('settings.dav.revoked'));
|
||||
load();
|
||||
} catch {
|
||||
toast.error(t('settings.dav.revokeFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
try {
|
||||
await client.delete(`/dav/passwords/${id}`);
|
||||
load();
|
||||
} catch {
|
||||
toast.error(t('settings.dav.deleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<p className="text-sm text-duck-dark/70 dark:text-foreground/70">
|
||||
Calendar and contacts sync uses one password per device. A phone can't hold an Officer session, so it gets its
|
||||
own credential — revocable on its own, without touching anything else.
|
||||
</p>
|
||||
|
||||
{minted && (
|
||||
<div className="grid gap-3 rounded-lg border-2 border-duck-teal/40 bg-duck-teal/5 p-4">
|
||||
<div className="text-sm font-medium text-duck-dark dark:text-foreground">
|
||||
Password for “{minted.label}” — shown once
|
||||
</div>
|
||||
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">
|
||||
This is the only time it is displayed. It's stored hashed, so it can't be shown again — if you lose it,
|
||||
revoke this entry and make another.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{[
|
||||
{ field: 'Server', value: serverUrl },
|
||||
{ field: 'Username', value: minted.username },
|
||||
{ field: 'Password', value: minted.password },
|
||||
].map(({ field, value }) => (
|
||||
<div key={field} className="flex items-center gap-2">
|
||||
<span className="w-20 shrink-0 text-xs text-duck-dark/50 dark:text-foreground/50">{field}</span>
|
||||
<code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs">
|
||||
{value}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copy(value, field)}
|
||||
title={t('settings.dav.copyField', { field })}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">
|
||||
In DAVx5 or iOS, choose “login with URL and username” and give it the server address above — not a full
|
||||
calendar path. Discovery does the rest.
|
||||
</p>
|
||||
|
||||
<Button variant="outline" size="sm" className="justify-self-start" onClick={() => setMinted(null)}>
|
||||
I've saved it
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={create} className="flex items-end gap-2">
|
||||
<Label className="grid flex-1 gap-2">
|
||||
<span className="text-duck-dark/70 dark:text-foreground/70">{t('settings.dav.newDevice')}</span>
|
||||
<Input
|
||||
className="h-11 bg-background/60 border-duck-dark/20"
|
||||
value={label}
|
||||
onChange={(ev) => setLabel(ev.target.value)}
|
||||
placeholder={t('settings.dav.placeholder')}
|
||||
/>
|
||||
</Label>
|
||||
<Button type="submit" disabled={!label.trim() || isCreating} className="h-11">
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
{t('settings.dav.create')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{passwords.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
|
||||
{t('settings.dav.none')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{passwords.map((entry) => {
|
||||
const revoked = !!entry.revokedAt;
|
||||
const lastUsed = entry.lastUsedAt
|
||||
? formatDate(entry.lastUsedAt, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`flex items-center gap-3 rounded-lg border p-3 ${
|
||||
revoked
|
||||
? 'border-duck-dark/10 dark:border-foreground/10 opacity-50'
|
||||
: 'border-duck-dark/15 dark:border-foreground/15'
|
||||
}`}
|
||||
>
|
||||
<Smartphone className="h-4 w-4 shrink-0 text-duck-teal/70" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-duck-dark dark:text-foreground">
|
||||
{entry.label}
|
||||
{revoked && <span className="ml-2 text-xs text-red-500">revoked</span>}
|
||||
</div>
|
||||
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
<code className="font-mono">{entry.hint}…</code>
|
||||
{' · '}
|
||||
{/* "never used" is the tell that a device was set up wrong, so it earns its own wording. */}
|
||||
{lastUsed ? `last used ${lastUsed}` : 'never used'}
|
||||
</div>
|
||||
</div>
|
||||
{!revoked && (
|
||||
<Button variant="ghost" size="sm" onClick={() => revoke(entry.id)} title={t('settings.dav.revoke')}>
|
||||
<Ban className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => remove(entry.id)}
|
||||
title={t('settings.dav.delete')}
|
||||
className="hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,7 +9,6 @@ import { SettingsRoute, SettingsSidebar, SettingsContent, type SettingsSection }
|
||||
import { GoogleOAuthConfig } from './GoogleOAuthConfig';
|
||||
// import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now
|
||||
import { ApifyConfig } from './ApifyConfig';
|
||||
import { DavAppPasswords } from './DavAppPasswords';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
|
||||
const BASE_PATH = '/settings/integrations';
|
||||
@@ -43,13 +42,9 @@ const personalSections: SettingsSection[] = [
|
||||
// descriptionKey: 'settings.sections.apify.description',
|
||||
// content: <GoogleAccount />,
|
||||
// },
|
||||
{
|
||||
key: 'dav-app-passwords',
|
||||
icon: CalendarDays,
|
||||
titleKey: 'settings.sections.davSync.label',
|
||||
descriptionKey: 'settings.sections.davSync.description',
|
||||
content: <DavAppPasswords />,
|
||||
},
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user