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

@@ -217,8 +217,9 @@ production habit data separate from development data.
server-side; Discord access/refresh tokens are never persisted or sent to React.
- React detects the browser timezone during initial registration. The server
validates it using `Intl.DateTimeFormat`, falling back to UTC. Later sign-ins
update the Discord profile without changing the saved timezone. A timezone
settings UI is deferred; `PATCH /api/me` updates the timezone. Habit timestamps
update the Discord profile without changing the saved timezone. **Settings** lets users change their timezone, download a complete JSON export,
and permanently delete their account with typed confirmation. `PATCH /api/me`
updates the timezone. Habit timestamps
remain UTC and daily boundaries follow the saved timezone with preserved historical
deadlines. See the API reference for travel and DST behavior.
@@ -325,3 +326,12 @@ Habit tests cover every route, all PRD acceptance behaviors, ownership and Origi
checks, historical corrections, expiry, timezone boundaries, daily resets, optional
count carryover, Nivo data adaptation, and migration upgrades. Semantic commits
separate the database foundation, REST implementation, carryover, and verification.
### Account data
`GET /api/account/export` downloads versioned JSON containing all owned profile,
habit, revision, dated progress, task, audit, calendar, chart, and delivery records.
It excludes session tokens and credentials. `DELETE /api/account` requires matching
Origin and `{ "confirmation": "DELETE" }`; it atomically removes owned records and
revokes every session. Discord posts and retained operator backups are not erased
by this action.

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">