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:
@@ -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" }),
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user