App passwords; CalDAV / CardDAV server for phones, Thunderbird, macOS

App passwords (SPEC App. D §D.5): one generated password per app, shown once,
revocable on its own, revoked on a password reset; never accepted by the web
sign-in. Settings → Security → App passwords (migration 0024).

CalDAV / CardDAV server (SPEC Appendix E), built in: /dav/ with
/.well-known/caldav|carddav, HTTP Basic with app passwords only (cached checks,
throttled failures). The calendar and contacts plugins serve their own tables as
collections (Plugin(dav=...)):
- calendars as iCalendar: series with overrides, time zones, all-day, attendees
  and RSVP, alarms <-> reminders; MKCALENDAR / PROPPATCH / DELETE of calendars;
  subscriptions read-only; writes to upstream-synced calendars flow on upstream;
- address books as vCard (3.0 served, 3.0/4.0 read), groups as CATEGORIES;
- what a client stores is served back verbatim until the web app edits it;
- sync tokens / CTags from change logs filled by triggers (calendar0008,
  contacts0002), so every write path counts; trimmed nightly after 90 days.
REPORTs: calendar-query (time range), calendar-/addressbook-multiget,
addressbook-query, sync-collection. nginx and the Vite proxy route /dav/.
Settings shows the server and user name to enter; install guide §5.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 21:49:04 +02:00
co-authored by Claude Opus 5.5
parent fa552392c8
commit 0e3793a4d6
30 changed files with 3968 additions and 4 deletions
+9
View File
@@ -2,6 +2,7 @@
// (SPEC §10.3) and includes cookies for the session (SPEC §6.1).
import type {
AppPassword,
AccountDetail,
AdminMail,
AdminSettings,
@@ -242,6 +243,14 @@ export const api = {
body: JSON.stringify({ password }),
}),
sessions: () => apiFetch<SessionInfo[]>("/auth/sessions"),
appPasswords: () => apiFetch<AppPassword[]>("/auth/app-passwords"),
createAppPassword: (name: string, password: string) =>
apiFetch<AppPassword & { app_password: string }>("/auth/app-passwords", {
method: "POST",
body: JSON.stringify({ name, password }),
}),
revokeAppPassword: (id: string) =>
apiFetch<{ message: string }>(`/auth/app-passwords/${id}`, { method: "DELETE" }),
revokeSession: (sid: string) =>
apiFetch<{ status: string }>(`/auth/sessions/${sid}`, { method: "DELETE" }),
+8
View File
@@ -345,3 +345,11 @@ export interface RemoteDraftOut {
snippet: string;
updated_at: string;
}
/** A password for one mail app (Settings → Security → App passwords). */
export interface AppPassword {
id: string;
name: string;
created_at: string;
last_used_at: string | null;
}
+185
View File
@@ -509,6 +509,189 @@ function OfflineAccessSetting() {
);
}
/**
* Settings → Security: a password for each mail app (Thunderbird, a phone's
* Mail app) — they sign in over IMAP/SMTP, where no 2FA code can be asked for.
* The password is shown once, when made; each can be revoked on its own.
*/
function AppPasswordsSetting() {
const t = useT();
const handle = useMe().data?.handle ?? "";
const queryClient = useQueryClient();
const list = useQuery({ queryKey: ["app-passwords"], queryFn: api.appPasswords });
const [creating, setCreating] = useState(false);
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [made, setMade] = useState<{ name: string; secret: string } | null>(null);
const [copied, setCopied] = useState(false);
const create = useMutation({
mutationFn: () => api.createAppPassword(name.trim(), password),
onSuccess: (out) => {
setMade({ name: out.name, secret: out.app_password });
setCreating(false);
setName("");
setPassword("");
setError("");
void queryClient.invalidateQueries({ queryKey: ["app-passwords"] });
},
onError: (e) => setError(e instanceof ApiError ? e.message : String(e)),
});
const revoke = useMutation({
mutationFn: (id: string) => api.revokeAppPassword(id),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["app-passwords"] }),
});
const copy = async () => {
if (!made) return;
try {
await navigator.clipboard.writeText(made.secret);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
/* the text is on screen to copy by hand */
}
};
const date = (iso: string) => new Date(iso).toLocaleDateString(getDateLocale(), { day: "numeric", month: "short", year: "numeric" });
return (
<div className="space-y-2">
<h2 className="text-lg font-semibold">{t("appPasswords.title")}</h2>
<p className="text-sm text-app-muted">{t("appPasswords.help")}</p>
{/* What to type into a calendar / contacts app (CalDAV / CardDAV, SPEC App. E). */}
<div className="space-y-1.5 rounded-lg border border-app-border p-3 text-sm">
<p className="font-medium">{t("appPasswords.connectTitle")}</p>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<dt className="text-app-muted">{t("appPasswords.connectServer")}</dt>
<dd className="select-all break-all font-mono text-[13px]">{`${window.location.origin}/dav/`}</dd>
<dt className="text-app-muted">{t("appPasswords.connectUser")}</dt>
<dd className="select-all break-all font-mono text-[13px]">{handle}</dd>
<dt className="text-app-muted">{t("appPasswords.connectPassword")}</dt>
<dd className="text-app-muted">{t("appPasswords.connectPasswordValue")}</dd>
</dl>
<p className="text-xs text-app-faint">{t("appPasswords.connectHint")}</p>
</div>
{made && (
<div className="space-y-2 rounded-lg border border-emerald-500/40 bg-emerald-500/5 p-3">
<p className="text-sm">{t("appPasswords.madeFor", { name: made.name })}</p>
<div className="flex flex-wrap items-center gap-3">
{/* Shown in groups of four; copied (and entered) without spaces. */}
<code className="select-all rounded bg-app-surface px-3 py-2 font-mono text-lg tracking-wider">
{made.secret.match(/.{1,4}/g)?.map((group, i) => (
// Spacing only, no characters: copying by hand gives the bare letters.
<span key={i} className={i ? "ml-2.5" : ""}>
{group}
</span>
))}
</code>
<button
className="rounded border border-app-border px-3 py-1.5 text-sm hover:bg-app-hover"
onClick={() => void copy()}
>
{copied ? t("appPasswords.copied") : t("appPasswords.copy")}
</button>
</div>
<p className="text-xs text-app-muted">{t("appPasswords.onceOnly")}</p>
<button className="rounded bg-app-accent px-3 py-1 text-sm text-app-accent-fg" onClick={() => setMade(null)}>
{t("appPasswords.done")}
</button>
</div>
)}
{(list.data ?? []).length > 0 && (
<ul className="divide-y divide-app-border rounded-lg border border-app-border">
{(list.data ?? []).map((a) => (
<li key={a.id} className="flex items-center justify-between gap-3 px-3 py-2 text-sm">
<span className="min-w-0">
<span className="break-words">{a.name}</span>
<span className="block text-xs text-app-faint">
{t("appPasswords.created", { date: date(a.created_at) })} ·{" "}
{a.last_used_at
? t("appPasswords.lastUsed", { date: date(a.last_used_at) })
: t("appPasswords.neverUsed")}
</span>
</span>
<button
className="shrink-0 text-xs text-red-500 disabled:opacity-50"
disabled={revoke.isPending}
onClick={() => {
if (confirm(t("appPasswords.confirmRevoke", { name: a.name }))) revoke.mutate(a.id);
}}
>
{t("settings.revoke")}
</button>
</li>
))}
</ul>
)}
{creating ? (
<form
className="space-y-2 rounded-lg border border-app-border p-3"
onSubmit={(e) => {
e.preventDefault();
create.mutate();
}}
>
<label className="block text-sm">
<span className="text-app-muted">{t("appPasswords.name")}</span>
<input
className="mt-1 w-full rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
placeholder={t("appPasswords.namePlaceholder")}
maxLength={60}
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
</label>
<label className="block text-sm">
<span className="text-app-muted">{t("appPasswords.confirmPassword")}</span>
<input
className="mt-1 w-full rounded border border-app-border bg-app-surface px-2 py-1 text-sm"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-2">
<button
type="submit"
className="rounded bg-app-accent px-3 py-1 text-sm text-app-accent-fg disabled:opacity-50"
disabled={!name.trim() || !password || create.isPending}
>
{t("appPasswords.create")}
</button>
<button
type="button"
className="px-2 text-sm text-app-muted"
onClick={() => {
setCreating(false);
setError("");
}}
>
{t("common.cancel")}
</button>
</div>
</form>
) : (
<button
className="rounded border border-app-border px-3 py-1.5 text-sm text-app-fg hover:bg-app-hover"
onClick={() => {
setMade(null);
setCreating(true);
}}
>
{t("appPasswords.new")}
</button>
)}
</div>
);
}
function SecuritySection() {
const t = useT();
const queryClient = useQueryClient();
@@ -626,6 +809,8 @@ function SecuritySection() {
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
<AppPasswordsSetting />
<div className="space-y-2">
<h2 className="text-lg font-semibold">{t("settings.sessions")}</h2>
<ul className="divide-y divide-app-border rounded-lg border border-app-border">
+4
View File
@@ -26,6 +26,10 @@ export default defineConfig({
changeOrigin: true,
ws: true,
},
// CalDAV / CardDAV (SPEC Appendix E), for trying clients against dev.
"/dav": { target: "http://127.0.0.1:8000", changeOrigin: true },
"/.well-known/caldav": { target: "http://127.0.0.1:8000", changeOrigin: true },
"/.well-known/carddav": { target: "http://127.0.0.1:8000", changeOrigin: true },
},
},
test: {