From 75666e5e94300231a33395c1396ef135c67e013a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 4 Aug 2026 03:33:41 +0000 Subject: [PATCH] settings: manage dav app passwords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../IntegrationsSettings/DavAppPasswords.tsx | 213 ++++++++++++++++++ .../Settings/IntegrationsSettings/index.tsx | 10 +- 2 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/DavAppPasswords.tsx diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/DavAppPasswords.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/DavAppPasswords.tsx new file mode 100644 index 00000000..682e78ee --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/DavAppPasswords.tsx @@ -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([]); + 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[] }>('/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 ( +
+

+ 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 ? ( +
+ No devices yet. +
+ ) : ( +
+ {passwords.map((entry) => { + const revoked = !!entry.revokedAt; + const lastUsed = formatDate(entry.lastUsedAt); + 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 && ( + + )} + +
+ ); + })} +
+ )} +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx index 78ce98f7..b40d6521 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx @@ -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: , // }, + { + key: 'dav-app-passwords', + icon: CalendarDays, + title: 'Calendar & Contacts sync', + description: 'Per-device passwords for CalDAV/CardDAV clients', + content: , + }, { key: 'browser-relay', icon: Globe,