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,