settings: manage dav app passwords

Settings → Integrations → Calendar & Contacts sync. without this there is no way
to mint a credential from the app, and minting one was the first step of testing
the whole caldav feature on a phone.

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 one fact drives the screen:
the new credential appears in a panel that stays until dismissed, with the
server url and username beside it, because once it is gone the only remedy is to
revoke and mint another.

the server url is read from window.location.origin rather than configured. it is
by definition the address that reached this page, so it is the one that will
work on the phone.

rows show the hint prefix and last-used date. "never used" is the tell that a
device was set up wrong, so it gets its own wording rather than a blank.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 03:33:41 +00:00
co-authored by Claude Opus 5
parent 8cf210eae5
commit 75666e5e94
2 changed files with 222 additions and 1 deletions
@@ -0,0 +1,213 @@
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';
// 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 };
const formatDate = (value: string | null) =>
value ? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null;
const copy = async (text: string, what: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success(`${what} copied`);
} catch {
toast.error('Could not copy — select and copy manually');
}
};
export const DavAppPasswords = () => {
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 () => {
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('Could not create the app password');
} finally {
setIsCreating(false);
}
};
const revoke = async (id: number) => {
try {
await client.post(`/dav/passwords/${id}/revoke`, {});
toast.success('Revoked — that device will stop syncing');
load();
} catch {
toast.error('Could not revoke');
}
};
const remove = async (id: number) => {
try {
await client.delete(`/dav/passwords/${id}`);
load();
} catch {
toast.error('Could not delete');
}
};
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={`Copy ${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>
)}
<div className="flex items-end gap-2">
<Label className="grid flex-1 gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">New device</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20"
value={label}
onChange={(ev) => setLabel(ev.target.value)}
onKeyDown={(ev) => ev.key === 'Enter' && create()}
placeholder="Pixel 9, iPad, Thunderbird…"
/>
</Label>
<Button onClick={create} disabled={!label.trim() || isCreating} className="h-11">
<Plus className="mr-1.5 h-4 w-4" />
Create
</Button>
</div>
{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">
No devices yet.
</div>
) : (
<div className="grid gap-2">
{passwords.map((entry) => {
const revoked = !!entry.revokedAt;
const lastUsed = formatDate(entry.lastUsedAt);
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="Revoke">
<Ban className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => remove(entry.id)}
title="Delete"
className="hover:text-red-500"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
);
})}
</div>
)}
</div>
);
};
@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { Puzzle, KeyRound, UserCircle, Globe, Wrench, Mail } from 'lucide-react';
import { Puzzle, KeyRound, UserCircle, Globe, Wrench, Mail, CalendarDays } from 'lucide-react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { useAuth } from 'hooks/useAuth';
@@ -12,6 +12,7 @@ import { GoogleOAuthConfig } from './GoogleOAuthConfig';
import { BrowserRelay } from './BrowserRelay';
import { ApifyConfig } from './ApifyConfig';
import { EmailAccounts } from './EmailAccounts';
import { DavAppPasswords } from './DavAppPasswords';
const GLOBAL_KEY = 'INTEGRATIONS_SETTINGS_SELECTED';
const TAB_KEY = 'INTEGRATIONS_SETTINGS_TAB';
@@ -50,6 +51,13 @@ const personalSections: SettingsSection[] = [
// description: 'Connect your Google account',
// content: <GoogleAccount />,
// },
{
key: 'dav-app-passwords',
icon: CalendarDays,
title: 'Calendar & Contacts sync',
description: 'Per-device passwords for CalDAV/CardDAV clients',
content: <DavAppPasswords />,
},
{
key: 'browser-relay',
icon: Globe,