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.
|
server-side; Discord access/refresh tokens are never persisted or sent to React.
|
||||||
- React detects the browser timezone during initial registration. The server
|
- React detects the browser timezone during initial registration. The server
|
||||||
validates it using `Intl.DateTimeFormat`, falling back to UTC. Later sign-ins
|
validates it using `Intl.DateTimeFormat`, falling back to UTC. Later sign-ins
|
||||||
update the Discord profile without changing the saved timezone. A timezone
|
update the Discord profile without changing the saved timezone. **Settings** lets users change their timezone, download a complete JSON export,
|
||||||
settings UI is deferred; `PATCH /api/me` updates the timezone. Habit timestamps
|
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
|
remain UTC and daily boundaries follow the saved timezone with preserved historical
|
||||||
deadlines. See the API reference for travel and DST behavior.
|
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
|
checks, historical corrections, expiry, timezone boundaries, daily resets, optional
|
||||||
count carryover, Nivo data adaptation, and migration upgrades. Semantic commits
|
count carryover, Nivo data adaptation, and migration upgrades. Semantic commits
|
||||||
separate the database foundation, REST implementation, carryover, and verification.
|
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 { createHabitRoutes } from "./habits/routes";
|
||||||
import { ApiError } from "./habits/service";
|
import { ApiError } from "./habits/service";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
@@ -16,6 +17,7 @@ export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDi
|
|||||||
return c.json({ status: "ok" });
|
return c.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
app.route("/api/auth", auth.routes);
|
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.get("/api/me", auth.requireAuth, c => c.json(c.get("user")));
|
||||||
app.route("/api", createHabitRoutes(db, auth, now ?? Date.now));
|
app.route("/api", createHabitRoutes(db, auth, now ?? Date.now));
|
||||||
app.route("/api/sharing", createSharingRoutes(db, auth, sharingConfig, now ?? Date.now, discordRequest));
|
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);
|
deleteCookie(c, sessionName, cookieOptions);
|
||||||
return c.body(null, 204);
|
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 { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useAuth } from "../components/AuthProvider";
|
import { useAuth } from "../components/AuthProvider";
|
||||||
import { DiscordSignInButton } from "../components/DiscordSignInButton";
|
import { DiscordSignInButton } from "../components/DiscordSignInButton";
|
||||||
@@ -165,6 +166,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [sharing, setSharing] = useState(false);
|
const [sharing, setSharing] = useState(false);
|
||||||
|
const [settings, setSettings] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [needsRefresh, setNeedsRefresh] = useState(false);
|
const [needsRefresh, setNeedsRefresh] = useState(false);
|
||||||
const saving = useRef(false);
|
const saving = useRef(false);
|
||||||
@@ -354,6 +356,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="ds-actions">
|
<div className="ds-actions">
|
||||||
|
<Button variant="text" disabled={busy} aria-expanded={settings} onClick={() => setSettings(!settings)}>Settings</Button>
|
||||||
<Button
|
<Button
|
||||||
id="add-habit"
|
id="add-habit"
|
||||||
disabled={adding || busy || loading || needsRefresh}
|
disabled={adding || busy || loading || needsRefresh}
|
||||||
@@ -373,6 +376,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</WelcomePanel>
|
</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()); }} />}
|
{sharing && today && <ShareProgress user={user} today={today} revision={revision} onClose={() => { setSharing(false); requestAnimationFrame(() => document.getElementById("open-sharing")?.focus()); }} />}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="ds-form-feedback" role="alert">
|
<div className="ds-form-feedback" role="alert">
|
||||||
|
|||||||
Reference in New Issue
Block a user