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([]); const [isLoading, setIsLoading] = useState(true); const [label, setLabel] = useState(''); const [isCreating, setIsCreating] = useState(false); const [minted, setMinted] = useState(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 (

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.

{minted && (
Password for “{minted.label}” — shown once

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.

{[ { field: 'Server', value: serverUrl }, { field: 'Username', value: minted.username }, { field: 'Password', value: minted.password }, ].map(({ field, value }) => (
{field} {value}
))}

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.

)}
{passwords.length === 0 ? (
{t('settings.dav.none')}
) : (
{passwords.map((entry) => { const revoked = !!entry.revokedAt; const lastUsed = entry.lastUsedAt ? formatDate(entry.lastUsedAt, { month: 'short', day: 'numeric', year: 'numeric' }) : null; return (
{entry.label} {revoked && revoked}
{entry.hint}… {' · '} {/* "never used" is the tell that a device was set up wrong, so it earns its own wording. */} {lastUsed ? `last used ${lastUsed}` : 'never used'}
{!revoked && ( )}
); })}
)}
); };