Files
caldav/web/AppPasswords.tsx
pastilhasandClaude Opus 5 dcbc9b102c calendar and contacts, extracted from the officer platform
one sidecar, one permission, one install — and two screens, because Calendar and
Contacts are two places you go rather than two sections of one page. the first
plugin to declare `screens`, which the platform gained for this.

what moved: the radicale sidecar, the four routers, the panel apps, the
app-password table and the settings form. the form became a modal on the
calendar screen — a plugin ships panels and cannot contribute a settings
section, which is the same wall the email plugin hit and the same answer.

the routes are /calendar and /contacts, not /caldav/calendar. they were core
routes and keeping them is not cosmetic: a bookmark, a dock tile someone
arranged, and a persisted layout all address them. only a first-party plugin may
choose its own segment; a third-party one is confined to /p/<publisher>/.

four paths outside /api come with it, above the account gate, because phones are
not browsers: DAVx5, iOS and thunderbird send HTTP basic on every request and
have nowhere to put a JWT. that is not "open" — the sync door demands a scoped
app password and resolves it to one user before radicale sees anything.

two things deliberately unchanged. the table is still dav_app_passwords, not
caldav_app_passwords: installs hold real rows and a naming convention is not
worth a data migration on a live machine. and the panel names still resolve
through the platform's catalogs, so the six locales they had as core screens
survive rather than dropping to english to make the refactor tidier.

per-user was already true and is worth writing down because the config said
otherwise: the sync door resolves an app password to an account, tells radicale
that id is the principal, and owner_only does the rest. the comment claiming
"the owner is the only principal that exists" had been stale since multi-user
landed on 2026-08-07.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 04:53:45 +00:00

227 lines
8.7 KiB
TypeScript

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 AppPasswords = () => {
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[] }>('/caldav/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 }>('/caldav/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(`/caldav/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(`/caldav/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>
);
};