Replace isPublic boolean with visibility enum (private/link/public) across the full stack. Add shares table to schema for future share link support. Update all services, routes, schemas, hooks, components, and tests. Plan: 32-01 (Setup Sharing System - Schema Migration) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
184 lines
5.8 KiB
TypeScript
184 lines
5.8 KiB
TypeScript
import { zValidator } from "@hono/zod-validator";
|
|
import { and, eq, inArray } from "drizzle-orm";
|
|
import { Hono } from "hono";
|
|
import type { db as prodDb } from "../../db/index.ts";
|
|
import {
|
|
apiKeys,
|
|
categories,
|
|
items,
|
|
oauthCodes,
|
|
oauthTokens,
|
|
settings,
|
|
setupItems,
|
|
setups,
|
|
threadCandidates,
|
|
threads,
|
|
users,
|
|
} from "../../db/schema.ts";
|
|
import {
|
|
changeEmailSchema,
|
|
changePasswordSchema,
|
|
deleteAccountSchema,
|
|
} from "../../shared/schemas.ts";
|
|
import { requireAuth } from "../middleware/auth.ts";
|
|
import { logtoClient } from "../services/logto.service.ts";
|
|
|
|
type Db = typeof prodDb;
|
|
type Env = { Variables: { db?: any; userId?: number } };
|
|
|
|
const app = new Hono<Env>();
|
|
|
|
// All account management routes require authentication (per T-28-05)
|
|
app.use("*", requireAuth);
|
|
|
|
// ── Helper ──────────────────────────────────────────────────────────
|
|
|
|
async function getLogtoSub(db: Db, userId: number): Promise<string> {
|
|
const [user] = await db
|
|
.select({ logtoSub: users.logtoSub })
|
|
.from(users)
|
|
.where(eq(users.id, userId));
|
|
if (!user) throw new Error("User not found");
|
|
return user.logtoSub;
|
|
}
|
|
|
|
// ── Change Password (D-05) ──────────────────────────────────────────
|
|
|
|
app.post("/password", zValidator("json", changePasswordSchema), async (c) => {
|
|
const db = c.get("db") as Db;
|
|
const userId = c.get("userId")!;
|
|
const { currentPassword, newPassword } = c.req.valid("json");
|
|
|
|
const logtoSub = await getLogtoSub(db, userId);
|
|
|
|
// Per T-28-03: ALWAYS verify current password before setting new one
|
|
const isValid = await logtoClient.verifyPassword(logtoSub, currentPassword);
|
|
if (!isValid) {
|
|
return c.json({ error: "Current password is incorrect" }, 400);
|
|
}
|
|
|
|
await logtoClient.updatePassword(logtoSub, newPassword);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
// ── Change Email (D-05) ─────────────────────────────────────────────
|
|
|
|
app.post("/email", zValidator("json", changeEmailSchema), async (c) => {
|
|
const db = c.get("db") as Db;
|
|
const userId = c.get("userId")!;
|
|
const { newEmail } = c.req.valid("json");
|
|
|
|
const logtoSub = await getLogtoSub(db, userId);
|
|
await logtoClient.updateEmail(logtoSub, newEmail);
|
|
return c.json({ ok: true, email: newEmail });
|
|
});
|
|
|
|
// ── Has Password ────────────────────────────────────────────────────
|
|
|
|
app.get("/has-password", async (c) => {
|
|
const db = c.get("db") as Db;
|
|
const userId = c.get("userId")!;
|
|
|
|
const logtoSub = await getLogtoSub(db, userId);
|
|
const hasPassword = await logtoClient.hasPassword(logtoSub);
|
|
return c.json({ hasPassword });
|
|
});
|
|
|
|
// ── Delete Account (D-05, D-06) ─────────────────────────────────────
|
|
|
|
app.post("/delete", zValidator("json", deleteAccountSchema), async (c) => {
|
|
const db = c.get("db") as Db;
|
|
const userId = c.get("userId")!;
|
|
|
|
const logtoSub = await getLogtoSub(db, userId);
|
|
|
|
// Per T-28-06: Run deletion in a single transaction
|
|
await db.transaction(async (tx) => {
|
|
// 1. Get or create sentinel "Deleted User" for public setup attribution (D-06)
|
|
let [sentinel] = await tx
|
|
.select({ id: users.id })
|
|
.from(users)
|
|
.where(eq(users.logtoSub, "deleted-user"));
|
|
|
|
if (!sentinel) {
|
|
[sentinel] = await tx
|
|
.insert(users)
|
|
.values({
|
|
logtoSub: "deleted-user",
|
|
displayName: "Deleted User",
|
|
})
|
|
.returning({ id: users.id });
|
|
}
|
|
|
|
// 2. Reassign public setups to sentinel user
|
|
await tx
|
|
.update(setups)
|
|
.set({ userId: sentinel.id })
|
|
.where(and(eq(setups.userId, userId), eq(setups.visibility, "public")));
|
|
|
|
// 3. Get private setup IDs for cleanup
|
|
const privateSetups = await tx
|
|
.select({ id: setups.id })
|
|
.from(setups)
|
|
.where(eq(setups.userId, userId));
|
|
|
|
if (privateSetups.length > 0) {
|
|
const privateSetupIds = privateSetups.map((s) => s.id);
|
|
// Delete setup items for private setups
|
|
await tx
|
|
.delete(setupItems)
|
|
.where(inArray(setupItems.setupId, privateSetupIds));
|
|
// Delete private setups
|
|
await tx.delete(setups).where(eq(setups.userId, userId));
|
|
}
|
|
|
|
// 4. Get thread IDs for candidate cleanup
|
|
const userThreads = await tx
|
|
.select({ id: threads.id })
|
|
.from(threads)
|
|
.where(eq(threads.userId, userId));
|
|
|
|
if (userThreads.length > 0) {
|
|
const threadIds = userThreads.map((t) => t.id);
|
|
// Delete thread candidates (cascade should handle this, but be explicit)
|
|
await tx
|
|
.delete(threadCandidates)
|
|
.where(inArray(threadCandidates.threadId, threadIds));
|
|
}
|
|
|
|
// 5. Delete threads
|
|
await tx.delete(threads).where(eq(threads.userId, userId));
|
|
|
|
// 6. Delete items
|
|
await tx.delete(items).where(eq(items.userId, userId));
|
|
|
|
// 7. Delete categories
|
|
await tx.delete(categories).where(eq(categories.userId, userId));
|
|
|
|
// 8. Delete API keys
|
|
await tx.delete(apiKeys).where(eq(apiKeys.userId, userId));
|
|
|
|
// 9. Delete settings
|
|
await tx.delete(settings).where(eq(settings.userId, userId));
|
|
|
|
// 10. Delete OAuth codes and tokens
|
|
await tx.delete(oauthCodes).where(eq(oauthCodes.userId, userId));
|
|
await tx.delete(oauthTokens).where(eq(oauthTokens.userId, userId));
|
|
|
|
// 11. Delete user record
|
|
await tx.delete(users).where(eq(users.id, userId));
|
|
});
|
|
|
|
// Delete user from Logto (outside transaction — external service)
|
|
try {
|
|
await logtoClient.deleteUser(logtoSub);
|
|
} catch (err) {
|
|
console.error("[account] Failed to delete Logto user:", err);
|
|
// Don't fail the request — local data is already cleaned up
|
|
}
|
|
|
|
return c.json({ ok: true, redirectTo: "/logout" });
|
|
});
|
|
|
|
export const accountRoutes = app;
|