diff --git a/README.md b/README.md index feef4dc..96f8a86 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/account/routes.test.ts b/src/account/routes.test.ts new file mode 100644 index 0000000..0994a98 --- /dev/null +++ b/src/account/routes.test.ts @@ -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(); } +}); diff --git a/src/account/routes.ts b/src/account/routes.ts new file mode 100644 index 0000000..af63085 --- /dev/null +++ b/src/account/routes.ts @@ -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, now: () => number) { + const app = new Hono(); + 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; +} diff --git a/src/api.ts b/src/api.ts index b224d3a..6c51dfc 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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)); diff --git a/src/auth/index.ts b/src/auth/index.ts index d91a8f2..3272b2c 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -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[0]) => deleteCookie(c, sessionName, cookieOptions) }; } diff --git a/src/components/AccountSettings.tsx b/src/components/AccountSettings.tsx new file mode 100644 index 0000000..f2d5442 --- /dev/null +++ b/src/components/AccountSettings.tsx @@ -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) { + 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
+ +
{ event.preventDefault(); void run(() => habitRequest("/me", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timezone }) })); }}> + + {(id, hintId) => setTimezone(event.target.value)} />} + + {["UTC", ...Intl.supportedValuesOf("timeZone")].map(zone => + +
+
+ Export my data + + +
+

Export includes your profile, habits, history, and sharing records. Sessions and credentials are excluded.

+ {deleting &&
{ event.preventDefault(); void run(() => habitRequest("/account", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirmation }) })); }}> +

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 operator’s retention policy.

+ {id => setConfirmation(event.target.value)} />} +
+
} + {error &&

{error}

} +
+
; +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 750f95e..b883d19 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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 )}
+