feat: add timezone settings data export and account deletion
This commit is contained in:
14
README.md
14
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.
|
||||
|
||||
33
src/account/routes.test.ts
Normal file
33
src/account/routes.test.ts
Normal 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
53
src/account/routes.ts
Normal 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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
46
src/components/AccountSettings.tsx
Normal file
46
src/components/AccountSettings.tsx
Normal 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 operator’s 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>;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user