feat: add timezone settings data export and account deletion

This commit is contained in:
syntaxbullet
2026-09-04 18:23:12 +02:00
parent e88ffceb22
commit d624ee6c3c
7 changed files with 151 additions and 3 deletions

View File

@@ -0,0 +1,33 @@
import { test, expect } from 'bun:test';
import { fixture } from '../habits/test-fixture';
test('export is complete, owned, and excludes sessions; deletion removes only the authenticated account', async () => {
const f = fixture();
try {
const a = await f.json('/habits', 'POST', { name: 'Private tasks', method: 'tasks', tasks: [{ name: 'Stretch' }] }, 201);
const b = await f.json('/habits', 'POST', { name: 'Bob only', method: 'manual' }, 201, 'b');
const day = await f.json(`/habits/${a.id}/days/2026-09-04`);
await f.json(`/habits/${a.id}/days/2026-09-04/tasks/${day.tasks[0].taskId}`, 'PUT', { done: true });
await f.json('/charts', 'POST', { name: 'Mine', habitIds: [a.id] }, 201);
const response = await f.request('/account/export');
expect(response.headers.get('content-disposition')).toContain('attachment');
const data = await response.json();
expect(data.habits.map((h: any) => h.id)).toEqual([a.id]);
expect(data.tasks[0].done).toBe(true);
expect(data.progressEvents.length).toBe(1);
expect(data.revisions.length).toBeGreaterThan(0);
expect(data.charts.length).toBe(1);
expect(JSON.stringify(data)).not.toContain('tokenHash');
expect(JSON.stringify(data)).not.toContain('Bob only');
expect((await f.request('/account', 'DELETE', { confirmation: 'DELETE' }, 'a', { Origin: 'https://wrong.test' })).status).toBe(403);
expect((await f.request('/account', 'DELETE', {})).status).toBe(422);
const removed = await f.request('/account', 'DELETE', { confirmation: 'DELETE' });
expect(removed.status).toBe(204);
expect(removed.headers.get('set-cookie')).toContain('Max-Age=0');
expect((await f.request('/me')).status).toBe(401);
expect((await f.request('/account/export')).status).toBe(401);
expect((await f.json('/habits', 'GET', undefined, 200, 'b')).habits.map((h: any) => h.id)).toEqual([b.id]);
expect(f.sqlite.query('PRAGMA foreign_key_check').all()).toEqual([]);
expect(f.sqlite.query('SELECT * FROM progress_events').all()).toEqual([]);
} finally { f.close(); }
});

53
src/account/routes.ts Normal file
View File

@@ -0,0 +1,53 @@
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import { eq, inArray } from "drizzle-orm";
import type { AppDatabase, AuthEnv, createAuth } from "../auth";
import * as schema from "../db/schema";
import { HabitService } from "../habits/service";
export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, now: () => number) {
const app = new Hono<AuthEnv>();
app.use("*", auth.requireAuth);
app.get("/export", c => {
const user = c.get("user");
const service = new HabitService(db, user, now()); service.sync();
const data = db.transaction(tx => {
const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, user.id));
const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned));
return {
formatVersion: 1, exportedAt: new Date(now()).toISOString(),
user: tx.select().from(schema.users).where(eq(schema.users.id, user.id)).get(),
habits: tx.select().from(schema.habits).where(eq(schema.habits.userId, user.id)).all(),
revisions: tx.select().from(schema.habitRevisions).where(inArray(schema.habitRevisions.habitId, owned)).all(),
days: tx.select().from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned)).all(),
tasks: tx.select().from(schema.taskOccurrences).where(inArray(schema.taskOccurrences.dayId, days)).all(),
progressEvents: tx.select().from(schema.progressEvents).where(inArray(schema.progressEvents.dayId, days)).all(),
calendarSettings: tx.select().from(schema.habitCalendarSettings).where(inArray(schema.habitCalendarSettings.habitId, owned)).all(),
charts: tx.select().from(schema.combinedCharts).where(eq(schema.combinedCharts.userId, user.id)).all(),
discordDeliveries: tx.select().from(schema.discordDeliveries).where(eq(schema.discordDeliveries.userId, user.id)).all(),
};
});
c.header("Content-Disposition", 'attachment; filename="minabot-export.json"');
return c.json(data);
});
app.delete("/", auth.requireSameOrigin, bodyLimit({ maxSize: 1024 }), async c => {
const body = await c.req.json().catch(() => null);
if (body?.confirmation !== "DELETE") return c.json({ error: "Type DELETE to confirm account deletion." }, 422);
const userId = c.get("user").id;
db.transaction(tx => {
const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, userId));
const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned));
tx.delete(schema.progressEvents).where(inArray(schema.progressEvents.dayId, days)).run();
tx.delete(schema.taskOccurrences).where(inArray(schema.taskOccurrences.dayId, days)).run();
tx.delete(schema.habitDays).where(inArray(schema.habitDays.habitId, owned)).run();
tx.delete(schema.habitRevisions).where(inArray(schema.habitRevisions.habitId, owned)).run();
tx.delete(schema.habitCalendarSettings).where(inArray(schema.habitCalendarSettings.habitId, owned)).run();
tx.delete(schema.combinedCharts).where(eq(schema.combinedCharts.userId, userId)).run();
tx.delete(schema.habits).where(eq(schema.habits.userId, userId)).run();
tx.delete(schema.users).where(eq(schema.users.id, userId)).run();
});
auth.clearSession(c);
return c.body(null, 204);
});
return app;
}

View File

@@ -1,3 +1,4 @@
import { createAccountRoutes } from "./account/routes";
import { createHabitRoutes } from "./habits/routes";
import { ApiError } from "./habits/service";
import { Hono } from "hono";
@@ -16,6 +17,7 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
return c.json({ status: "ok" });
});
app.route("/api/auth", auth.routes);
app.route("/api/account", createAccountRoutes(db, auth, now ?? Date.now));
app.get("/api/me", auth.requireAuth, c => c.json(c.get("user")));
app.route("/api", createHabitRoutes(db, auth, now ?? Date.now));
app.route("/api/sharing", createSharingRoutes(db, auth, sharingConfig, now ?? Date.now, discordRequest));

View File

@@ -142,5 +142,5 @@ export function createAuth(db: AppDatabase, config: AuthConfig, request: FetchDi
deleteCookie(c, sessionName, cookieOptions);
return c.body(null, 204);
});
return { routes, requireAuth, requireSameOrigin };
return { routes, requireAuth, requireSameOrigin, clearSession: (c: Parameters<typeof requireAuth>[0]) => deleteCookie(c, sessionName, cookieOptions) };
}

View File

@@ -0,0 +1,46 @@
import { useId, useRef, useState } from "react";
import type { PublicUser } from "../shared/user";
import { habitRequest } from "../lib/dashboard";
import { Button, ButtonLink } from "./design-system/primitives";
import { Card } from "./design-system/Card";
import { Field } from "./design-system/Field";
export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser; onChanged: () => void; onClose: () => void }) {
const [timezone, setTimezone] = useState(user.timezone);
const [confirmation, setConfirmation] = useState("");
const [deleting, setDeleting] = useState(false);
const [busy, setBusy] = useState(false);
const saving = useRef(false);
const [error, setError] = useState("");
const listId = useId();
async function run(action: () => Promise<unknown>) {
if (saving.current) return;
saving.current = true; setBusy(true); setError("");
try { await action(); onChanged(); }
catch (error) { setError(error instanceof Error ? error.message : "Could not save. Try again."); }
finally { saving.current = false; setBusy(false); }
}
return <section className="ds-section" id="account-settings" aria-label="Account settings">
<Card heading="Account settings" headingLevel={2}>
<form onSubmit={event => { event.preventDefault(); void run(() => habitRequest("/me", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timezone }) })); }}>
<Field label="Timezone" hint="Daily check-ins follow this timezone. Earlier deadlines stay as recorded.">
{(id, hintId) => <input id={id} aria-describedby={hintId} list={listId} value={timezone} required maxLength={100} disabled={busy} onChange={event => setTimezone(event.target.value)} />}
</Field>
<datalist id={listId}>{["UTC", ...Intl.supportedValuesOf("timeZone")].map(zone => <option key={zone} value={zone} />)}</datalist>
<Button type="submit" disabled={busy}>Save timezone</Button>
</form>
<div className="ds-actions">
<ButtonLink href="/api/account/export" download="minabot-export.json">Export my data</ButtonLink>
<Button variant="text" disabled={busy} onClick={() => setDeleting(!deleting)} aria-expanded={deleting}>Delete account</Button>
<Button variant="text" disabled={busy} onClick={onClose}>Close settings</Button>
</div>
<p className="type-small">Export includes your profile, habits, history, and sharing records. Sessions and credentials are excluded.</p>
{deleting && <form onSubmit={event => { event.preventDefault(); void run(() => habitRequest("/account", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirmation }) })); }}>
<p>This permanently deletes your account and habit history and signs out every device. Download an export first if you want a copy. Images already posted to Discord remain there; server backups expire under the operators retention policy.</p>
<Field label="Type DELETE to confirm">{id => <input id={id} value={confirmation} disabled={busy} autoComplete="off" onChange={event => setConfirmation(event.target.value)} />}</Field>
<div className="ds-actions"><Button type="submit" disabled={busy || confirmation !== "DELETE"}>Permanently delete my account</Button><Button variant="text" disabled={busy} onClick={() => { setDeleting(false); setConfirmation(""); }}>Cancel deletion</Button></div>
</form>}
{error && <p role="alert">{error}</p>}
</Card>
</section>;
}

View File

@@ -1,3 +1,4 @@
import { AccountSettings } from "../components/AccountSettings";
import { useCallback, useEffect, useRef, useState } from "react";
import { useAuth } from "../components/AuthProvider";
import { DiscordSignInButton } from "../components/DiscordSignInButton";
@@ -165,6 +166,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
const [loading, setLoading] = useState(true);
const [adding, setAdding] = useState(false);
const [sharing, setSharing] = useState(false);
const [settings, setSettings] = useState(false);
const [busy, setBusy] = useState(false);
const [needsRefresh, setNeedsRefresh] = useState(false);
const saving = useRef(false);
@@ -354,6 +356,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
)}
</div>
<div className="ds-actions">
<Button variant="text" disabled={busy} aria-expanded={settings} onClick={() => setSettings(!settings)}>Settings</Button>
<Button
id="add-habit"
disabled={adding || busy || loading || needsRefresh}
@@ -373,6 +376,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
</>
)}
</WelcomePanel>
{settings && <AccountSettings user={user} onChanged={onExpired} onClose={() => setSettings(false)} />}
{sharing && today && <ShareProgress user={user} today={today} revision={revision} onClose={() => { setSharing(false); requestAnimationFrame(() => document.getElementById("open-sharing")?.focus()); }} />}
{error && (
<div className="ds-form-feedback" role="alert">