diff --git a/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx b/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx index 8e5bd04..3f2ec2b 100644 --- a/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx +++ b/apps/web/src/app/[locale]/dashboard/(user)/layout.tsx @@ -36,7 +36,7 @@ const menu = [ label: "manage", items: [ { - title: "settings", + title: "account", href: pathsConfig.dashboard.user.settings.index, icon: Icons.Settings, }, diff --git a/apps/web/src/app/[locale]/dashboard/(user)/settings/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/settings/page.tsx index 5569b74..77e6b2b 100644 --- a/apps/web/src/app/[locale]/dashboard/(user)/settings/page.tsx +++ b/apps/web/src/app/[locale]/dashboard/(user)/settings/page.tsx @@ -7,6 +7,7 @@ import { DeleteAccount } from "~/modules/user/settings/general/delete-account"; import { EditAvatar } from "~/modules/user/settings/general/edit-avatar"; import { EditEmail } from "~/modules/user/settings/general/edit-email"; import { EditName } from "~/modules/user/settings/general/edit-name"; +import { ExportData } from "~/modules/user/settings/general/export-data"; import { LanguageSwitcher } from "~/modules/user/settings/general/language-switcher"; export const generateMetadata = getMetadata({ @@ -27,6 +28,7 @@ export default async function SettingsPage() { + ); diff --git a/apps/web/src/modules/user/settings/general/export-data.tsx b/apps/web/src/modules/user/settings/general/export-data.tsx new file mode 100644 index 0000000..032c788 --- /dev/null +++ b/apps/web/src/modules/user/settings/general/export-data.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useState } from "react"; + +import { Button } from "@turbostarter/ui-web/button"; + +export const ExportData = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const onExport = async () => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/my/export", { credentials: "include" }); + if (!res.ok) { + throw new Error(`Export failed (${res.status})`); + } + const data = (await res.json()) as { user: { id: string } }; + const blob = new Blob([JSON.stringify(data, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const date = new Date().toISOString().slice(0, 10); + const a = document.createElement("a"); + a.href = url; + a.download = `claudemesh-export-${data.user.id}-${date}.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (e) { + setError(e instanceof Error ? e.message : "Export failed"); + } finally { + setLoading(false); + } + }; + + return ( +
+

Export your data

+

+ Download a JSON file with your profile, meshes you own, meshes you + joined, invites you've issued, and audit events from your owned + meshes. Read-only. +

+ + {error &&

{error}

} +
+ ); +}; diff --git a/packages/api/src/modules/mesh/queries.ts b/packages/api/src/modules/mesh/queries.ts index ed59610..7232a4f 100644 --- a/packages/api/src/modules/mesh/queries.ts +++ b/packages/api/src/modules/mesh/queries.ts @@ -10,7 +10,7 @@ import { or, sql, } from "@turbostarter/db"; -import { invite, mesh, meshMember } from "@turbostarter/db/schema"; +import { auditLog, invite, mesh, meshMember } from "@turbostarter/db/schema"; import { db } from "@turbostarter/db/server"; import type { GetMyMeshesInput } from "../../schema"; @@ -163,6 +163,87 @@ export const getMyMeshById = async ({ }; }; +export const getMyExport = async ({ userId }: { userId: string }) => { + const meshesOwned = await db + .select({ + id: mesh.id, + name: mesh.name, + slug: mesh.slug, + visibility: mesh.visibility, + transport: mesh.transport, + tier: mesh.tier, + createdAt: mesh.createdAt, + archivedAt: mesh.archivedAt, + }) + .from(mesh) + .where(eq(mesh.ownerUserId, userId)); + + const memberships = await db + .select({ + meshId: meshMember.meshId, + meshName: mesh.name, + meshSlug: mesh.slug, + memberId: meshMember.id, + displayName: meshMember.displayName, + role: meshMember.role, + joinedAt: meshMember.joinedAt, + revokedAt: meshMember.revokedAt, + }) + .from(meshMember) + .leftJoin(mesh, eq(meshMember.meshId, mesh.id)) + .where(eq(meshMember.userId, userId)); + + const invitesSent = await db + .select({ + id: invite.id, + meshId: invite.meshId, + meshSlug: mesh.slug, + role: invite.role, + maxUses: invite.maxUses, + usedCount: invite.usedCount, + expiresAt: invite.expiresAt, + createdAt: invite.createdAt, + revokedAt: invite.revokedAt, + }) + .from(invite) + .leftJoin(mesh, eq(invite.meshId, mesh.id)) + .where(eq(invite.createdBy, userId)); + + // Audit events for the user's owned meshes only (privacy: don't leak + // events from meshes the user merely joined) + const meshIds = meshesOwned.map((m) => m.id); + const auditEvents = + meshIds.length > 0 + ? await db + .select({ + id: auditLog.id, + meshId: auditLog.meshId, + eventType: auditLog.eventType, + actorPeerId: auditLog.actorPeerId, + targetPeerId: auditLog.targetPeerId, + metadata: sql>`${auditLog.metadata}`, + createdAt: auditLog.createdAt, + }) + .from(auditLog) + .where( + sql`${auditLog.meshId} = ANY(ARRAY[${sql.join( + meshIds.map((id) => sql`${id}`), + sql`, `, + )}]::text[])`, + ) + .orderBy(desc(auditLog.createdAt)) + .limit(5000) + : []; + + return { + exportedAt: new Date().toISOString(), + meshesOwned, + memberships, + invitesSent, + auditEvents, + }; +}; + export const getMyInvitesSent = async ({ userId }: { userId: string }) => db .select({ diff --git a/packages/api/src/modules/mesh/router.ts b/packages/api/src/modules/mesh/router.ts index 1c87aa9..a41c0c9 100644 --- a/packages/api/src/modules/mesh/router.ts +++ b/packages/api/src/modules/mesh/router.ts @@ -16,6 +16,7 @@ import { leaveMyMesh, } from "./mutations"; import { + getMyExport, getMyInvitesSent, getMyMeshById, getMyMeshes, @@ -111,4 +112,17 @@ export const myRouter = new Hono() .get("/invites", async (c) => { const user = c.var.user; return c.json({ sent: await getMyInvitesSent({ userId: user.id }) }); + }) + .get("/export", async (c) => { + const user = c.var.user; + const data = await getMyExport({ userId: user.id }); + return c.json({ + user: { + id: user.id, + email: user.email, + name: user.name, + createdAt: user.createdAt, + }, + ...data, + }); });